diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f31588..c36f8ec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,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. - 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 7557c234..2b46ef2f 100644 --- a/docs/commands/gog-calendar-events.md +++ b/docs/commands/gog-calendar-events.md @@ -33,12 +33,13 @@ gog calendar (cal) events (list,ls) [ ...] [flags] | `--fail-empty`
`--non-empty`
`--require-results` | `bool` | | Exit with code 3 if no results | | `--fields` | `string` | | Comma-separated fields to return | | `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | -| `--from` | `string` | | Start time (RFC3339 with timezone, date, or relative: today, tomorrow, monday) | +| `--from` | `string` | | Start time (RFC3339 with timezone, date, or relative: now, today, tomorrow, monday) | | `--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) | | `--max`
`--limit` | `int64` | 10 | Max results | | `--no-input`
`--non-interactive`
`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) | +| `--order` | `string` | asc | Sort order | | `--page`
`--cursor` | `string` | | Page token | | `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | | `--private-prop-filter` | `string` | | Filter by private extended property (key=value) | @@ -46,7 +47,8 @@ gog calendar (cal) events (list,ls) [ ...] [flags] | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | | `--shared-prop-filter` | `string` | | Filter by shared extended property (key=value) | -| `--to` | `string` | | End time (RFC3339 with timezone, date, or relative) | +| `--sort` | `string` | | Sort events by start\|end\|summary\|calendar (default: keep API order; with --all, start is recommended for chronological output) | +| `--to` | `string` | | End time (RFC3339 with timezone, date, or relative: now, today, tomorrow, monday) | | `--today` | `bool` | | Today only (timezone-aware) | | `--tomorrow` | `bool` | | Tomorrow only (timezone-aware) | | `-v`
`--verbose` | `bool` | | Enable verbose logging | diff --git a/docs/commands/gog-calendar-search.md b/docs/commands/gog-calendar-search.md index 015c9067..a612534f 100644 --- a/docs/commands/gog-calendar-search.md +++ b/docs/commands/gog-calendar-search.md @@ -28,7 +28,7 @@ gog calendar (cal) search (find,query) [flags] | `-n`
`--dry-run`
`--dryrun`
`--noop`
`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully | | `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) | | `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | -| `--from` | `string` | | Start time (RFC3339, date, or relative: today, tomorrow, monday) | +| `--from` | `string` | | Start time (RFC3339, date, or relative: now, today, tomorrow, monday) | | `--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) | @@ -37,7 +37,7 @@ gog calendar (cal) search (find,query) [flags] | `-p`
`--plain`
`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | -| `--to` | `string` | | End time (RFC3339, date, or relative) | +| `--to` | `string` | | End time (RFC3339, date, or relative: now, today, tomorrow, monday) | | `--today` | `bool` | | Today only | | `--tomorrow` | `bool` | | Tomorrow only | | `-v`
`--verbose` | `bool` | | Enable verbose logging | diff --git a/docs/commands/gog-calendar-team.md b/docs/commands/gog-calendar-team.md index db7ae169..0b81668d 100644 --- a/docs/commands/gog-calendar-team.md +++ b/docs/commands/gog-calendar-team.md @@ -28,7 +28,7 @@ gog calendar (cal) team [flags] | `--enable-commands` | `string` | | Comma-separated list of enabled commands; dot paths allowed (restricts CLI) | | `-y`
`--force`
`--assume-yes`
`--yes` | `bool` | | Skip confirmations for destructive commands | | `--freebusy` | `bool` | | Show only busy/free blocks (faster, single API call) | -| `--from` | `string` | | Start time (RFC3339, date, or relative: today, tomorrow, monday) | +| `--from` | `string` | | Start time (RFC3339, date, or relative: now, today, tomorrow, monday) | | `--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) | @@ -39,7 +39,7 @@ gog calendar (cal) team [flags] | `-q`
`--query` | `string` | | Filter events by title (case-insensitive) | | `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) | | `--select`
`--pick`
`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. | -| `--to` | `string` | | End time (RFC3339, date, or relative) | +| `--to` | `string` | | End time (RFC3339, date, or relative: now, today, tomorrow, monday) | | `--today` | `bool` | | Today only | | `--tomorrow` | `bool` | | Tomorrow only | | `-v`
`--verbose` | `bool` | | Enable verbose logging | diff --git a/internal/cmd/calendar_all_events_test.go b/internal/cmd/calendar_all_events_test.go index cfb602a6..c4b0b821 100644 --- a/internal/cmd/calendar_all_events_test.go +++ b/internal/cmd/calendar_all_events_test.go @@ -5,6 +5,8 @@ import ( "net/http" "strings" "testing" + + "google.golang.org/api/calendar/v3" ) func TestListAllCalendarsEvents_JSON(t *testing.T) { @@ -60,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, "", ""); runErr != nil { t.Fatalf("listAllCalendarsEvents: %v", runErr) } }) @@ -75,3 +77,108 @@ func TestListAllCalendarsEvents_JSON(t *testing.T) { t.Fatalf("unexpected events: %#v", parsed.Events) } } + +// TestListAllCalendarsEvents_SortByStart verifies that --sort=start orders +// events from multiple calendars chronologically (default API order returns +// them grouped per calendar in iteration order). +func TestListAllCalendarsEvents_SortByStart(t *testing.T) { + svc, closeSvc := newCalendarServiceForTest(t, withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/calendarList") && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{{"id": "cal1"}, {"id": "cal2"}}, + }) + return + case 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": "late", "summary": "Late", "status": "confirmed", + "start": map[string]any{"dateTime": "2025-01-01T15:00:00Z"}, + "end": map[string]any{"dateTime": "2025-01-01T16:00:00Z"}, + }, + }, + }) + return + case strings.Contains(r.URL.Path, "/calendars/cal2/events") && r.Method == http.MethodGet: + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + { + "id": "early", "summary": "Early", "status": "confirmed", + "start": map[string]any{"dateTime": "2025-01-01T08:00:00Z"}, + "end": map[string]any{"dateTime": "2025-01-01T09:00:00Z"}, + }, + }, + }) + return + } + http.NotFound(w, r) + }))) + defer closeSvc() + + 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 { + t.Fatalf("listAllCalendarsEvents: %v", err) + } + }) + + var parsed struct { + Events []map[string]any `json:"events"` + } + if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil { + t.Fatalf("json parse: %v", err) + } + if len(parsed.Events) != 2 { + t.Fatalf("expected 2 events, got %#v", parsed.Events) + } + if got, _ := parsed.Events[0]["id"].(string); got != "early" { + t.Fatalf("expected first event id 'early', got %q (events: %#v)", got, parsed.Events) + } + if got, _ := parsed.Events[1]["id"].(string); got != "late" { + t.Fatalf("expected second event id 'late', got %q (events: %#v)", got, parsed.Events) + } + + // 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 { + t.Fatalf("listAllCalendarsEvents desc: %v", err) + } + }) + if err := json.Unmarshal([]byte(jsonOut), &parsed); err != nil { + t.Fatalf("json parse desc: %v", err) + } + if got, _ := parsed.Events[0]["id"].(string); got != "late" { + t.Fatalf("desc: expected first id 'late', got %q", got) + } +} + +// TestSortEventsBy_Summary verifies case-insensitive summary sort works on +// the wrapper slice independent of API call wiring. +func TestSortEventsBy_Summary(t *testing.T) { + events := []*eventWithCalendar{ + {Event: &calendar.Event{Summary: "banana"}}, + {Event: &calendar.Event{Summary: "Apple"}}, + {Event: &calendar.Event{Summary: "cherry"}}, + } + sortEventsBy(events, "summary", "asc") + got := []string{events[0].Summary, events[1].Summary, events[2].Summary} + want := []string{"Apple", "banana", "cherry"} + for i := range got { + if got[i] != want[i] { + t.Fatalf("summary asc: got %v want %v", got, want) + } + } + + sortEventsBy(events, "summary", "desc") + got = []string{events[0].Summary, events[1].Summary, events[2].Summary} + want = []string{"cherry", "banana", "Apple"} + for i := range got { + if got[i] != want[i] { + t.Fatalf("summary desc: got %v want %v", got, want) + } + } +} diff --git a/internal/cmd/calendar_events_cmds.go b/internal/cmd/calendar_events_cmds.go index 3988099b..314c8a24 100644 --- a/internal/cmd/calendar_events_cmds.go +++ b/internal/cmd/calendar_events_cmds.go @@ -13,8 +13,8 @@ type CalendarEventsCmd struct { CalendarID []string `arg:"" name:"calendarId" optional:"" help:"Calendar ID (default: primary); optional leading list/ls selector is accepted for compatibility"` Cal []string `name:"cal" help:"Calendar ID or name (can be repeated)"` Calendars string `name:"calendars" help:"Comma-separated calendar IDs, names, or indices from 'calendar calendars'"` - From string `name:"from" help:"Start time (RFC3339 with timezone, date, or relative: today, tomorrow, monday)"` - To string `name:"to" help:"End time (RFC3339 with timezone, date, or relative)"` + From string `name:"from" help:"Start time (RFC3339 with timezone, date, or relative: now, today, tomorrow, monday)"` + To string `name:"to" help:"End time (RFC3339 with timezone, date, or relative: now, today, tomorrow, monday)"` Today bool `name:"today" help:"Today only (timezone-aware)"` Tomorrow bool `name:"tomorrow" help:"Tomorrow only (timezone-aware)"` Week bool `name:"week" help:"This week (uses --week-start, default Mon)"` @@ -30,6 +30,8 @@ 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}"` + 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"` } func (c *CalendarEventsCmd) Run(ctx context.Context, flags *RootFlags) error { @@ -80,7 +82,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) + 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) } if len(calInputs) > 0 { ids, err := resolveCalendarIDs(ctx, svc, calInputs) @@ -90,9 +92,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) + 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 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, 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 77015fd6..c807d574 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, "", ""); 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, "", ""); err != nil { t.Fatalf("listCalendarEvents: %v", err) } }) @@ -127,7 +127,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, "", ""); err != nil { t.Fatalf("listCalendarEvents: %v", err) } }) diff --git a/internal/cmd/calendar_list.go b/internal/cmd/calendar_list.go index 43fa4db7..3f6fccae 100644 --- a/internal/cmd/calendar_list.go +++ b/internal/cmd/calendar_list.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "sort" "strings" "time" @@ -41,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) 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, 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() @@ -55,22 +56,27 @@ func listCalendarEvents(ctx context.Context, svc *calendar.Service, calendarID, if err != nil { return err } + events := make([]*eventWithCalendar, 0, len(items)) + for _, item := range items { + events = append(events, wrapEventWithCalendar(item, "", calendarTimezone, loc)) + } + sortEventsBy(events, sortKey, sortOrder) if outfmt.IsJSON(ctx) { + jsonItems := make([]*eventWithDays, 0, len(events)) + for _, e := range events { + jsonItems = append(jsonItems, wrapEventWithDaysWithTimezone(e.Event, calendarTimezone, loc)) + } if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ - "events": wrapEventsWithTimezone(items, calendarTimezone, loc), + "events": jsonItems, "nextPageToken": nextPageToken, }); err != nil { return err } - if len(items) == 0 { + if len(events) == 0 { return failEmptyExit(failEmpty) } return nil } - events := make([]*eventWithCalendar, 0, len(items)) - for _, item := range items { - events = append(events, wrapEventWithCalendar(item, "", calendarTimezone, loc)) - } return renderCalendarEventsTable(ctx, events, nextPageToken, false, showWeekday, failEmpty, true) } @@ -105,7 +111,7 @@ 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) error { +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 { u := ui.FromContext(ctx) calendars, err := listCalendarList(ctx, svc) @@ -129,14 +135,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)) + return listCalendarIDsEvents(ctx, svc, ids, from, to, maxResults, page, allPages, failEmpty, query, privatePropFilter, sharedPropFilter, fields, showWeekday, 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) error { - return listCalendarIDsEvents(ctx, svc, calendarIDs, from, to, maxResults, page, allPages, failEmpty, query, privatePropFilter, sharedPropFilter, fields, showWeekday, nil) +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 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 { +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 { u := ui.FromContext(ctx) all := []*eventWithCalendar{} for _, calID := range calendarIDs { @@ -164,6 +170,8 @@ func listCalendarIDsEvents(ctx context.Context, svc *calendar.Service, calendarI } } + sortEventsBy(all, sortKey, sortOrder) + if outfmt.IsJSON(ctx) { if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"events": all}); err != nil { return err @@ -221,17 +229,6 @@ func renderCalendarEventsTable(ctx context.Context, events []*eventWithCalendar, return nil } -func wrapEventsWithTimezone(events []*calendar.Event, calendarTimezone string, loc *time.Location) []*eventWithDays { - if len(events) == 0 { - return []*eventWithDays{} - } - out := make([]*eventWithDays, 0, len(events)) - for _, ev := range events { - out = append(out, wrapEventWithDaysWithTimezone(ev, calendarTimezone, loc)) - } - return out -} - func wrapEventWithCalendar(event *calendar.Event, calendarID string, calendarTimezone string, loc *time.Location) *eventWithCalendar { wrapped := wrapEventWithDaysWithTimezone(event, calendarTimezone, loc) if wrapped == nil { @@ -331,3 +328,98 @@ func listCalendarList(ctx context.Context, svc *calendar.Service) ([]*calendar.C } return items, nil } + +// sortEventsBy sorts events in place by the given key (start|end|summary|calendar). +// An empty key leaves the slice untouched. The Google Calendar API already +// returns per-calendar events ordered by startTime; this helper is mainly useful +// when aggregating events across multiple calendars (e.g. --all) or when callers +// want a non-default ordering. Sort is stable to preserve API tie-breaks. +// +// Time keys (start, end) compare as instants (parsed time.Time), so events +// crossing timezones interleave correctly. String keys (summary, calendar) +// compare case-insensitive for summary, exact for calendar id. +func sortEventsBy(events []*eventWithCalendar, key, order string) { + key = strings.ToLower(strings.TrimSpace(key)) + if key == "" || len(events) < 2 { + return + } + desc := strings.ToLower(strings.TrimSpace(order)) == "desc" + + switch key { + case "start", "end": + instantFn := eventStartInstant + if key == "end" { + instantFn = eventEndInstant + } + sort.SliceStable(events, func(i, j int) bool { + a, b := instantFn(events[i]), instantFn(events[j]) + if a.Equal(b) { + return false + } + if desc { + return a.After(b) + } + return a.Before(b) + }) + case "summary": + sort.SliceStable(events, func(i, j int) bool { + a, b := strings.ToLower(eventSummary(events[i])), strings.ToLower(eventSummary(events[j])) + if desc { + return a > b + } + return a < b + }) + case "calendar": + sort.SliceStable(events, func(i, j int) bool { + a, b := eventCalendarID(events[i]), eventCalendarID(events[j]) + if desc { + return a > b + } + return a < b + }) + } +} + +func eventSummary(e *eventWithCalendar) string { + if e == nil || e.Event == nil { + return "" + } + return e.Summary +} + +func eventCalendarID(e *eventWithCalendar) string { + if e == nil { + return "" + } + return e.CalendarID +} + +// eventStartInstant returns the start time as an absolute instant. +// All-day events fall back to midnight UTC, which is consistent enough for +// ordering within a single result set. +func eventStartInstant(e *eventWithCalendar) time.Time { + if e == nil || e.Event == nil || e.Start == nil { + return time.Time{} + } + return eventDatePointInstant(e.Start) +} + +func eventEndInstant(e *eventWithCalendar) time.Time { + if e == nil || e.Event == nil || e.End == nil { + return time.Time{} + } + return eventDatePointInstant(e.End) +} + +func eventDatePointInstant(dt *calendar.EventDateTime) time.Time { + if dt == nil { + return time.Time{} + } + if t, ok := parseEventTime(dt.DateTime, dt.TimeZone); ok { + return t + } + if t, ok := parseEventDate(dt.Date, dt.TimeZone); ok { + return t + } + return time.Time{} +} diff --git a/internal/cmd/time_helpers.go b/internal/cmd/time_helpers.go index 7b5027f8..33b3f22a 100644 --- a/internal/cmd/time_helpers.go +++ b/internal/cmd/time_helpers.go @@ -17,8 +17,8 @@ import ( // TimeRangeFlags provides common time range options for calendar commands. // Embed this struct in commands that need time range support. type TimeRangeFlags struct { - From string `name:"from" help:"Start time (RFC3339, date, or relative: today, tomorrow, monday)"` - To string `name:"to" help:"End time (RFC3339, date, or relative)"` + From string `name:"from" help:"Start time (RFC3339, date, or relative: now, today, tomorrow, monday)"` + To string `name:"to" help:"End time (RFC3339, date, or relative: now, today, tomorrow, monday)"` Today bool `name:"today" help:"Today only"` Tomorrow bool `name:"tomorrow" help:"Tomorrow only"` Week bool `name:"week" help:"This week (uses --week-start, default Mon)"`