diff --git a/CHANGELOG.md b/CHANGELOG.md index 337cbda8..3fa8eccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs/commands/gog-calendar-events.md b/docs/commands/gog-calendar-events.md index 2b46ef2f..93b2b1d8 100644 --- a/docs/commands/gog-calendar-events.md +++ b/docs/commands/gog-calendar-events.md @@ -37,6 +37,7 @@ gog calendar (cal) events (list,ls) [ ...] [flags] | `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) | | `-h`
`--help` | `kong.helpFlag` | | Show context-sensitive help. | | `-j`
`--json`
`--machine` | `bool` | false | Output JSON to stdout (best for scripting) | +| `--location` | `bool` | | Include event LOCATION column in table output | | `--max`
`--limit` | `int64` | 10 | Max results | | `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | | `--order` | `string` | asc | Sort order | diff --git a/internal/cmd/calendar_all_events_test.go b/internal/cmd/calendar_all_events_test.go index c4b0b821..82b697c1 100644 --- a/internal/cmd/calendar_all_events_test.go +++ b/internal/cmd/calendar_all_events_test.go @@ -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) } }) @@ -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) } }) @@ -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) } }) diff --git a/internal/cmd/calendar_events_cmds.go b/internal/cmd/calendar_events_cmds.go index 314c8a24..a542577f 100644 --- a/internal/cmd/calendar_events_cmds.go +++ b/internal/cmd/calendar_events_cmds.go @@ -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"` } @@ -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) @@ -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) { diff --git a/internal/cmd/calendar_events_test.go b/internal/cmd/calendar_events_test.go index c807d574..70d5a6d2 100644 --- a/internal/cmd/calendar_events_test.go +++ b/internal/cmd/calendar_events_test.go @@ -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) } }) @@ -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) } }) @@ -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 { @@ -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) } }) diff --git a/internal/cmd/calendar_list.go b/internal/cmd/calendar_list.go index 3f6fccae..a12b03bf 100644 --- a/internal/cmd/calendar_list.go +++ b/internal/cmd/calendar_list.go @@ -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() @@ -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 { @@ -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) @@ -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 { @@ -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") @@ -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)) } } } @@ -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 diff --git a/internal/googleauth/identity_migration.go b/internal/googleauth/identity_migration.go index 64451e14..e634a2c2 100644 --- a/internal/googleauth/identity_migration.go +++ b/internal/googleauth/identity_migration.go @@ -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 {