diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f31588..ee334775 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Calendar: show event `LOCATION` column by default in `calendar events` table output (appears after `SUMMARY` in every variant — `--all`, single-calendar, with/without `--weekday`). Embedded newlines in the location string are collapsed so multi-line addresses still render on one row. - 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/internal/cmd/calendar_events_test.go b/internal/cmd/calendar_events_test.go index 77015fd6..8dfd2d1a 100644 --- a/internal/cmd/calendar_events_test.go +++ b/internal/cmd/calendar_events_test.go @@ -95,6 +95,55 @@ func TestListCalendarEvents_TableUsesCalendarTimezone(t *testing.T) { } } +// TestListCalendarEvents_TableIncludesLocation asserts that the events list +// table renders the LOCATION column by default 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); err != nil { + t.Fatalf("listCalendarEvents: %v", err) + } + }) + + if !strings.Contains(text, "LOCATION") { + t.Fatalf("expected LOCATION header in output, 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 { diff --git a/internal/cmd/calendar_list.go b/internal/cmd/calendar_list.go index 43fa4db7..2bf81937 100644 --- a/internal/cmd/calendar_list.go +++ b/internal/cmd/calendar_list.go @@ -188,30 +188,30 @@ func renderCalendarEventsTable(ctx context.Context, events []*eventWithCalendar, if showWeekday { if includeCalendar { - fmt.Fprintln(w, "CALENDAR\tID\tSTART\tSTART_DOW\tEND\tEND_DOW\tSUMMARY") + fmt.Fprintln(w, "CALENDAR\tID\tSTART\tSTART_DOW\tEND\tEND_DOW\tSUMMARY\tLOCATION") 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\t%s\n", e.CalendarID, e.Id, eventDisplayStart(e), e.StartDayOfWeek, eventDisplayEnd(e), e.EndDayOfWeek, e.Summary, eventDisplayLocation(e)) } } else { - fmt.Fprintln(w, "ID\tSTART\tSTART_DOW\tEND\tEND_DOW\tSUMMARY") + fmt.Fprintln(w, "ID\tSTART\tSTART_DOW\tEND\tEND_DOW\tSUMMARY\tLOCATION") 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\t%s\n", e.Id, eventDisplayStart(e), startDay, eventDisplayEnd(e), endDay, e.Summary, eventDisplayLocation(e)) } } } else { if includeCalendar { - fmt.Fprintln(w, "CALENDAR\tID\tSTART\tEND\tSUMMARY") + fmt.Fprintln(w, "CALENDAR\tID\tSTART\tEND\tSUMMARY\tLOCATION") 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\t%s\n", e.CalendarID, e.Id, eventDisplayStart(e), eventDisplayEnd(e), e.Summary, eventDisplayLocation(e)) } } else { - fmt.Fprintln(w, "ID\tSTART\tEND\tSUMMARY") + fmt.Fprintln(w, "ID\tSTART\tEND\tSUMMARY\tLOCATION") 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\t%s\n", e.Id, eventDisplayStart(e), eventDisplayEnd(e), e.Summary, eventDisplayLocation(e)) } } } @@ -269,6 +269,25 @@ func eventDisplayEnd(e *eventWithCalendar) string { return eventEnd(e.Event) } +// 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