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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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".
- 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
109 changes: 108 additions & 1 deletion internal/cmd/calendar_all_events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"net/http"
"strings"
"testing"

"google.golang.org/api/calendar/v3"
)

func TestListAllCalendarsEvents_JSON(t *testing.T) {
Expand Down Expand Up @@ -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)
}
})
Expand All @@ -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)
}
}
}
12 changes: 7 additions & 5 deletions internal/cmd/calendar_events_cmds.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)"`
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand Down
6 changes: 3 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, "", ""); 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, "", ""); err != nil {
t.Fatalf("listCalendarEvents: %v", err)
}
})
Expand Down Expand Up @@ -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)
}
})
Expand Down
Loading