Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Added

- Calendar: add `calendar events --sort=start|end|summary|calendar` and `--order=asc|desc` so `--all` output can be returned chronologically across calendars instead of per-calendar API iteration order. Also documents `now` in the `--from`/`--to` help strings (already accepted by `timeparse`) — the relative form agents need when planning "from now on" — thanks @gado-ships-it.
- Calendar: add `calendar events --location` to include event locations in table output. Embedded newlines in the location string are collapsed so multi-line addresses still render on one row — thanks @gado-ships-it.
- Drive: add `drive share --notify` for invite targets that require a Drive notification email.
- Calendar: keep `calendar appointments` as an explicit diagnostic because the Calendar API still rejects `eventTypes=appointmentSchedule`. (#329)
- CLI: add nested `docs tabs ...` and `forms questions ...` aliases for consistent sub-item command patterns while preserving existing flat commands. (#433)
Expand Down
1 change: 1 addition & 0 deletions docs/commands/gog-calendar-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ gog calendar (cal) events (list,ls) [<calendarId> ...] [flags]
| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) |
| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. |
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
| `--location` | `bool` | | Include event LOCATION column in table output |
| `--max`<br>`--limit` | `int64` | 10 | Max results |
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
| `--order` | `string` | asc | Sort order |
Expand Down
6 changes: 3 additions & 3 deletions internal/cmd/calendar_all_events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func TestListAllCalendarsEvents_JSON(t *testing.T) {
ctx := newCalendarJSONContext(t)

jsonOut := captureStdout(t, func() {
if runErr := listAllCalendarsEvents(ctx, svc, "2025-01-01T00:00:00Z", "2025-01-02T00:00:00Z", 10, "", false, false, "", "", "", "", false, "", ""); runErr != nil {
if runErr := listAllCalendarsEvents(ctx, svc, "2025-01-01T00:00:00Z", "2025-01-02T00:00:00Z", 10, "", false, false, "", "", "", "", false, false, "", ""); runErr != nil {
t.Fatalf("listAllCalendarsEvents: %v", runErr)
}
})
Expand Down Expand Up @@ -121,7 +121,7 @@ func TestListAllCalendarsEvents_SortByStart(t *testing.T) {

ctx := newCalendarJSONContext(t)
jsonOut := captureStdout(t, func() {
if err := listAllCalendarsEvents(ctx, svc, "2025-01-01T00:00:00Z", "2025-01-02T00:00:00Z", 10, "", false, false, "", "", "", "", false, "start", "asc"); err != nil {
if err := listAllCalendarsEvents(ctx, svc, "2025-01-01T00:00:00Z", "2025-01-02T00:00:00Z", 10, "", false, false, "", "", "", "", false, false, "start", "asc"); err != nil {
t.Fatalf("listAllCalendarsEvents: %v", err)
}
})
Expand All @@ -144,7 +144,7 @@ func TestListAllCalendarsEvents_SortByStart(t *testing.T) {

// Descending order flips it.
jsonOut = captureStdout(t, func() {
if err := listAllCalendarsEvents(ctx, svc, "2025-01-01T00:00:00Z", "2025-01-02T00:00:00Z", 10, "", false, false, "", "", "", "", false, "start", "desc"); err != nil {
if err := listAllCalendarsEvents(ctx, svc, "2025-01-01T00:00:00Z", "2025-01-02T00:00:00Z", 10, "", false, false, "", "", "", "", false, false, "start", "desc"); err != nil {
t.Fatalf("listAllCalendarsEvents desc: %v", err)
}
})
Expand Down
7 changes: 4 additions & 3 deletions internal/cmd/calendar_events_cmds.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type CalendarEventsCmd struct {
SharedPropFilter string `name:"shared-prop-filter" help:"Filter by shared extended property (key=value)"`
Fields string `name:"fields" help:"Comma-separated fields to return"`
Weekday bool `name:"weekday" help:"Include start/end day-of-week columns" default:"${calendar_weekday}"`
Location bool `name:"location" help:"Include event LOCATION column in table output"`
Sort string `name:"sort" help:"Sort events by start|end|summary|calendar (default: keep API order; with --all, start is recommended for chronological output)" enum:"start,end,summary,calendar," default:""`
Order string `name:"order" help:"Sort order" enum:"asc,desc" default:"asc"`
}
Expand Down Expand Up @@ -82,7 +83,7 @@ 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, c.Sort, c.Order)
return listAllCalendarsEvents(ctx, svc, from, to, c.Max, c.Page, c.AllPages, c.FailEmpty, c.Query, c.PrivatePropFilter, c.SharedPropFilter, c.Fields, c.Weekday, c.Location, c.Sort, c.Order)
}
if len(calInputs) > 0 {
ids, err := resolveCalendarIDs(ctx, svc, calInputs)
Expand All @@ -92,9 +93,9 @@ 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, c.Sort, c.Order)
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, c.Location, c.Sort, c.Order)
}
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, c.Sort, c.Order)
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, c.Location, c.Sort, c.Order)
}

func normalizeCalendarEventsArgs(args []string) (string, error) {
Expand Down
66 changes: 63 additions & 3 deletions internal/cmd/calendar_events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func TestListCalendarEvents_JSON(t *testing.T) {
ctx := newCalendarJSONContext(t)

jsonOut := captureStdout(t, func() {
if err := listCalendarEvents(ctx, svc, "cal1", "2025-01-01T00:00:00Z", "2025-01-02T00:00:00Z", 10, "", false, false, "", "", "", "", false, "", ""); err != nil {
if err := listCalendarEvents(ctx, svc, "cal1", "2025-01-01T00:00:00Z", "2025-01-02T00:00:00Z", 10, "", false, false, "", "", "", "", false, false, "", ""); err != nil {
t.Fatalf("listCalendarEvents: %v", err)
}
})
Expand Down Expand Up @@ -82,7 +82,7 @@ func TestListCalendarEvents_TableUsesCalendarTimezone(t *testing.T) {

text := captureStdout(t, func() {
ctx := newCalendarOutputContext(t, os.Stdout, io.Discard)
if err := listCalendarEvents(ctx, svc, "cal1", "2026-04-08T00:00:00Z", "2026-04-09T00:00:00Z", 10, "", false, false, "", "", "", "", false, "", ""); err != nil {
if err := listCalendarEvents(ctx, svc, "cal1", "2026-04-08T00:00:00Z", "2026-04-09T00:00:00Z", 10, "", false, false, "", "", "", "", false, false, "", ""); err != nil {
t.Fatalf("listCalendarEvents: %v", err)
}
})
Expand All @@ -95,6 +95,66 @@ func TestListCalendarEvents_TableUsesCalendarTimezone(t *testing.T) {
}
}

// TestListCalendarEvents_TableIncludesLocation asserts that the events list
// table renders the LOCATION column when requested and that embedded newlines in
// the location string are collapsed so the row stays on one line.
func TestListCalendarEvents_TableIncludesLocation(t *testing.T) {
svc, closeServer := newCalendarServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/calendars/cal1/events") && r.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"items": []map[string]any{
{
"id": "e1",
"summary": "Standup",
"location": "Bahnhofstrasse 1\n8001 Zürich",
"start": map[string]any{"dateTime": "2026-04-08T09:00:00Z"},
"end": map[string]any{"dateTime": "2026-04-08T09:15:00Z"},
},
{
"id": "e2",
"summary": "No-location event",
"start": map[string]any{"dateTime": "2026-04-08T10:00:00Z"},
"end": map[string]any{"dateTime": "2026-04-08T10:15:00Z"},
},
},
})
return
}
http.NotFound(w, r)
}))
defer closeServer()

text := captureStdout(t, func() {
ctx := newCalendarOutputContext(t, os.Stdout, io.Discard)
if err := listCalendarEvents(ctx, svc, "cal1", "2026-04-08T00:00:00Z", "2026-04-09T00:00:00Z", 10, "", false, false, "", "", "", "", false, false, "", ""); err != nil {
t.Fatalf("listCalendarEvents: %v", err)
}
})

if strings.Contains(text, "LOCATION") {
t.Fatalf("did not expect LOCATION header without --location, got: %q", text)
}

text = captureStdout(t, func() {
ctx := newCalendarOutputContext(t, os.Stdout, io.Discard)
if err := listCalendarEvents(ctx, svc, "cal1", "2026-04-08T00:00:00Z", "2026-04-09T00:00:00Z", 10, "", false, false, "", "", "", "", false, true, "", ""); err != nil {
t.Fatalf("listCalendarEvents with location: %v", err)
}
})

if !strings.Contains(text, "LOCATION") {
t.Fatalf("expected LOCATION header with --location, got: %q", text)
}
if !strings.Contains(text, "Bahnhofstrasse 1 8001 Zürich") {
t.Fatalf("expected collapsed multi-line location, got: %q", text)
}
// Original newline must not leak into the rendered row.
if strings.Contains(text, "Bahnhofstrasse 1\n8001 Zürich") {
t.Fatalf("expected newline in location to be collapsed, got: %q", text)
}
}

func TestListCalendarEvents_JSONUsesCalendarTimezoneForLocalFields(t *testing.T) {
svc, closeServer := newCalendarServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
Expand Down Expand Up @@ -127,7 +187,7 @@ func TestListCalendarEvents_JSONUsesCalendarTimezoneForLocalFields(t *testing.T)

ctx := newCalendarJSONContext(t)
jsonOut := captureStdout(t, func() {
if err := listCalendarEvents(ctx, svc, "cal1", "2026-04-08T00:00:00Z", "2026-04-09T00:00:00Z", 10, "", false, false, "", "", "", "", false, "", ""); err != nil {
if err := listCalendarEvents(ctx, svc, "cal1", "2026-04-08T00:00:00Z", "2026-04-09T00:00:00Z", 10, "", false, false, "", "", "", "", false, false, "", ""); err != nil {
t.Fatalf("listCalendarEvents: %v", err)
}
})
Expand Down
78 changes: 61 additions & 17 deletions internal/cmd/calendar_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func calendarEventsListCall(ctx context.Context, svc *calendar.Service, calendar
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, sortKey, sortOrder string) error {
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, showLocation bool, sortKey, sortOrder 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()
Expand Down Expand Up @@ -77,7 +77,7 @@ func listCalendarEvents(ctx context.Context, svc *calendar.Service, calendarID,
}
return nil
}
return renderCalendarEventsTable(ctx, events, nextPageToken, false, showWeekday, failEmpty, true)
return renderCalendarEventsTable(ctx, events, nextPageToken, false, showWeekday, showLocation, failEmpty, true)
}

type eventWithCalendar struct {
Expand Down Expand Up @@ -111,7 +111,9 @@ type calendarTimezoneHint struct {
loc *time.Location
}

func listAllCalendarsEvents(ctx context.Context, svc *calendar.Service, from, to string, maxResults int64, page string, allPages bool, failEmpty bool, query, privatePropFilter, sharedPropFilter, fields string, showWeekday bool, sortKey, sortOrder string) error {
const calendarLocationColumnSuffix = "\tLOCATION"

func listAllCalendarsEvents(ctx context.Context, svc *calendar.Service, from, to string, maxResults int64, page string, allPages bool, failEmpty bool, query, privatePropFilter, sharedPropFilter, fields string, showWeekday bool, showLocation bool, sortKey, sortOrder string) error {
u := ui.FromContext(ctx)

calendars, err := listCalendarList(ctx, svc)
Expand All @@ -135,14 +137,14 @@ func listAllCalendarsEvents(ctx context.Context, svc *calendar.Service, from, to
u.Err().Println("No calendars")
return nil
}
return listCalendarIDsEvents(ctx, svc, ids, from, to, maxResults, page, allPages, failEmpty, query, privatePropFilter, sharedPropFilter, fields, showWeekday, calendarTimezoneHints(calendars), sortKey, sortOrder)
return listCalendarIDsEvents(ctx, svc, ids, from, to, maxResults, page, allPages, failEmpty, query, privatePropFilter, sharedPropFilter, fields, showWeekday, showLocation, calendarTimezoneHints(calendars), sortKey, sortOrder)
}

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, sortKey, sortOrder string) error {
return listCalendarIDsEvents(ctx, svc, calendarIDs, from, to, maxResults, page, allPages, failEmpty, query, privatePropFilter, sharedPropFilter, fields, showWeekday, nil, sortKey, sortOrder)
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, showLocation bool, sortKey, sortOrder string) error {
return listCalendarIDsEvents(ctx, svc, calendarIDs, from, to, maxResults, page, allPages, failEmpty, query, privatePropFilter, sharedPropFilter, fields, showWeekday, showLocation, nil, sortKey, sortOrder)
}

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, sortKey, sortOrder string) error {
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, showLocation bool, timezoneHints map[string]calendarTimezoneHint, sortKey, sortOrder string) error {
u := ui.FromContext(ctx)
all := []*eventWithCalendar{}
for _, calID := range calendarIDs {
Expand Down Expand Up @@ -181,10 +183,10 @@ func listCalendarIDsEvents(ctx context.Context, svc *calendar.Service, calendarI
}
return nil
}
return renderCalendarEventsTable(ctx, all, "", true, showWeekday, failEmpty, false)
return renderCalendarEventsTable(ctx, all, "", true, showWeekday, showLocation, failEmpty, false)
}

func renderCalendarEventsTable(ctx context.Context, events []*eventWithCalendar, nextPageToken string, includeCalendar, showWeekday, failEmpty bool, printPageHint bool) error {
func renderCalendarEventsTable(ctx context.Context, events []*eventWithCalendar, nextPageToken string, includeCalendar, showWeekday, showLocation, failEmpty bool, printPageHint bool) error {
u := ui.FromContext(ctx)
if len(events) == 0 {
u.Err().Println("No events")
Expand All @@ -196,30 +198,46 @@ func renderCalendarEventsTable(ctx context.Context, events []*eventWithCalendar,

if showWeekday {
if includeCalendar {
fmt.Fprintln(w, "CALENDAR\tID\tSTART\tSTART_DOW\tEND\tEND_DOW\tSUMMARY")
header := "CALENDAR\tID\tSTART\tSTART_DOW\tEND\tEND_DOW\tSUMMARY"
if showLocation {
header += calendarLocationColumnSuffix
}
fmt.Fprintln(w, header)
for _, e := range events {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", e.CalendarID, e.Id, eventDisplayStart(e), e.StartDayOfWeek, eventDisplayEnd(e), e.EndDayOfWeek, e.Summary)
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s%s\n", e.CalendarID, e.Id, eventDisplayStart(e), e.StartDayOfWeek, eventDisplayEnd(e), e.EndDayOfWeek, e.Summary, eventLocationCell(e, showLocation))
}
} else {
fmt.Fprintln(w, "ID\tSTART\tSTART_DOW\tEND\tEND_DOW\tSUMMARY")
header := "ID\tSTART\tSTART_DOW\tEND\tEND_DOW\tSUMMARY"
if showLocation {
header += calendarLocationColumnSuffix
}
fmt.Fprintln(w, header)
for _, e := range events {
startDay, endDay := e.StartDayOfWeek, e.EndDayOfWeek
if startDay == "" && endDay == "" {
startDay, endDay = eventDaysOfWeek(e.Event)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", e.Id, eventDisplayStart(e), startDay, eventDisplayEnd(e), endDay, e.Summary)
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s%s\n", e.Id, eventDisplayStart(e), startDay, eventDisplayEnd(e), endDay, e.Summary, eventLocationCell(e, showLocation))
}
}
} else {
if includeCalendar {
fmt.Fprintln(w, "CALENDAR\tID\tSTART\tEND\tSUMMARY")
header := "CALENDAR\tID\tSTART\tEND\tSUMMARY"
if showLocation {
header += calendarLocationColumnSuffix
}
fmt.Fprintln(w, header)
for _, e := range events {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", e.CalendarID, e.Id, eventDisplayStart(e), eventDisplayEnd(e), e.Summary)
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s%s\n", e.CalendarID, e.Id, eventDisplayStart(e), eventDisplayEnd(e), e.Summary, eventLocationCell(e, showLocation))
}
} else {
fmt.Fprintln(w, "ID\tSTART\tEND\tSUMMARY")
header := "ID\tSTART\tEND\tSUMMARY"
if showLocation {
header += calendarLocationColumnSuffix
}
fmt.Fprintln(w, header)
for _, e := range events {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", e.Id, eventDisplayStart(e), eventDisplayEnd(e), e.Summary)
fmt.Fprintf(w, "%s\t%s\t%s\t%s%s\n", e.Id, eventDisplayStart(e), eventDisplayEnd(e), e.Summary, eventLocationCell(e, showLocation))
}
}
}
Expand Down Expand Up @@ -266,6 +284,32 @@ func eventDisplayEnd(e *eventWithCalendar) string {
return eventEnd(e.Event)
}

func eventLocationCell(e *eventWithCalendar, showLocation bool) string {
if !showLocation {
return ""
}
return "\t" + eventDisplayLocation(e)
}

// eventDisplayLocation returns the event location formatted for a single
// table cell. Newlines are collapsed and the value is trimmed so a multi-line
// address from the Calendar API does not break the row layout.
func eventDisplayLocation(e *eventWithCalendar) string {
if e == nil || e.Event == nil {
return ""
}
loc := strings.TrimSpace(e.Location)
if loc == "" {
return ""
}
// Calendar locations occasionally arrive with embedded newlines (pasted
// multi-line addresses); collapse them so the row stays on one line.
loc = strings.ReplaceAll(loc, "\r\n", " ")
loc = strings.ReplaceAll(loc, "\n", " ")
loc = strings.ReplaceAll(loc, "\t", " ")
return loc
}

func calendarDisplayTimezone(ctx context.Context, svc *calendar.Service, calendarID string, hints map[string]calendarTimezoneHint) (string, *time.Location) {
if hint, ok := hints[calendarID]; ok {
return hint.timezone, hint.loc
Expand Down
2 changes: 1 addition & 1 deletion internal/googleauth/identity_migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func MigrateStoredSubjectIdentity(store secrets.Store, client string, identity I
// Subject migration is best-effort compatibility cleanup. A stale or
// corrupted token must not make a freshly completed OAuth flow fail
// before the new refresh token is saved.
return "", nil
return "", nil //nolint:nilerr // best-effort cleanup must not block saving the new token
}

for _, tok := range tokens {
Expand Down
Loading