Skip to content

Commit 23b847c

Browse files
fix(calendar): parse freebusy date ranges like events (#811)
Co-authored-by: Peter Steinberger <[email protected]> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
1 parent 4a532bb commit 23b847c

7 files changed

Lines changed: 191 additions & 12 deletions

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## 0.27.1 - Unreleased
44

5+
### Fixed
6+
7+
- Calendar: accept relative and date-only `freebusy --from`/`--to` values using the same timezone-aware range parsing as events. (#806, #811) — thanks @privatenumber.
8+
59
## 0.27.0 - 2026-06-14
610

711
### Added

docs/commands/gog-calendar-freebusy.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ gog calendar (cal) freebusy [<calendarIds>] [flags]
2929
| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) |
3030
| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children |
3131
| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands |
32-
| `--from` | `string` | | Start time (RFC3339, required) |
32+
| `--from` | `string` | | Start time (RFC3339 with timezone, date, or relative: now, today, tomorrow, monday) |
3333
| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) |
3434
| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. |
3535
| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) |
@@ -38,7 +38,7 @@ gog calendar (cal) freebusy [<calendarIds>] [flags]
3838
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
3939
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
4040
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
41-
| `--to` | `string` | | End time (RFC3339, required) |
41+
| `--to` | `string` | | End time (RFC3339 with timezone, date, or relative: now, today, tomorrow, monday) |
4242
| `-v`<br>`--verbose` | `bool` | | Enable verbose logging |
4343
| `--version` | `kong.VersionFlag` | | Print version and exit |
4444
| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers |

internal/cmd/calendar_freebusy.go

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cmd
33
import (
44
"context"
55
"strings"
6+
"time"
67

78
"google.golang.org/api/calendar/v3"
89

@@ -14,8 +15,8 @@ type CalendarFreeBusyCmd struct {
1415
CalendarIDs string `arg:"" optional:"" name:"calendarIds" help:"Comma-separated calendar IDs, names, or indices from 'calendar calendars'"`
1516
Cal []string `name:"cal" help:"Calendar ID, name, or index (can be repeated)"`
1617
All bool `name:"all" help:"Query all calendars"`
17-
From string `name:"from" help:"Start time (RFC3339, required)"`
18-
To string `name:"to" help:"End time (RFC3339, required)"`
18+
From string `name:"from" help:"Start time (RFC3339 with timezone, date, or relative: now, today, tomorrow, monday)"`
19+
To string `name:"to" help:"End time (RFC3339 with timezone, date, or relative: now, today, tomorrow, monday)"`
1920
}
2021

2122
func (c *CalendarFreeBusyCmd) Run(ctx context.Context, flags *RootFlags) error {
@@ -38,9 +39,14 @@ func (c *CalendarFreeBusyCmd) Run(ctx context.Context, flags *RootFlags) error {
3839
return err
3940
}
4041

42+
from, to, err := c.resolveFreeBusyRange(ctx, svc)
43+
if err != nil {
44+
return err
45+
}
46+
4147
req := &calendar.FreeBusyRequest{
42-
TimeMin: c.From,
43-
TimeMax: c.To,
48+
TimeMin: from,
49+
TimeMax: to,
4450
Items: make([]*calendar.FreeBusyRequestItem, 0, len(calendarIDs)),
4551
}
4652
for _, id := range calendarIDs {
@@ -63,3 +69,24 @@ func (c *CalendarFreeBusyCmd) Run(ctx context.Context, flags *RootFlags) error {
6369

6470
return outfmt.WriteTable(ctx, stdoutWriter(ctx), calendarFreeBusyRows(resp.Calendars), calendarFreeBusyColumns())
6571
}
72+
73+
func (c *CalendarFreeBusyCmd) resolveFreeBusyRange(ctx context.Context, svc *calendar.Service) (string, string, error) {
74+
if isRFC3339Instant(c.From) && isRFC3339Instant(c.To) {
75+
return c.From, c.To, nil
76+
}
77+
78+
timeRange, err := ResolveTimeRange(ctx, svc, TimeRangeFlags{
79+
From: c.From,
80+
To: c.To,
81+
})
82+
if err != nil {
83+
return "", "", err
84+
}
85+
from, to := timeRange.FormatRFC3339()
86+
return from, to, nil
87+
}
88+
89+
func isRFC3339Instant(value string) bool {
90+
_, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(value))
91+
return err == nil
92+
}

internal/cmd/calendar_freebusy_conflicts_selection_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"sort"
99
"strings"
1010
"testing"
11+
"time"
1112

1213
"google.golang.org/api/calendar/v3"
1314
"google.golang.org/api/option"
@@ -73,6 +74,127 @@ func TestCalendarFreeBusyCmd_ResolvesCalendarName(t *testing.T) {
7374
}
7475
}
7576

77+
func TestCalendarFreeBusyCmd_RFC3339RangeDoesNotFetchTimezone(t *testing.T) {
78+
var got struct {
79+
TimeMin string `json:"timeMin"`
80+
TimeMax string `json:"timeMax"`
81+
}
82+
var unexpected []string
83+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
84+
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
85+
if r.Method == http.MethodPost && strings.Contains(path, "/freeBusy") {
86+
_ = json.NewDecoder(r.Body).Decode(&got)
87+
w.Header().Set("Content-Type", "application/json")
88+
_ = json.NewEncoder(w).Encode(map[string]any{
89+
"calendars": map[string]any{
90+
"[email protected]": map[string]any{"busy": []map[string]any{}},
91+
},
92+
})
93+
return
94+
}
95+
96+
unexpected = append(unexpected, r.Method+" "+path)
97+
http.NotFound(w, r)
98+
}))
99+
defer srv.Close()
100+
101+
svc, err := calendar.NewService(context.Background(),
102+
option.WithoutAuthentication(),
103+
option.WithHTTPClient(srv.Client()),
104+
option.WithEndpoint(srv.URL+"/"),
105+
)
106+
if err != nil {
107+
t.Fatalf("NewService: %v", err)
108+
}
109+
result := executeWithCalendarTestService(t, []string{
110+
"--json",
111+
"--account", "[email protected]",
112+
"calendar", "freebusy",
113+
114+
"--from", "2026-01-10T00:00:00-08:00",
115+
"--to", "2026-01-11T00:00:00-08:00",
116+
}, svc)
117+
if result.err != nil {
118+
t.Fatalf("Execute: %v", result.err)
119+
}
120+
if len(unexpected) != 0 {
121+
t.Fatalf("unexpected pre-freebusy requests: %#v", unexpected)
122+
}
123+
if got.TimeMin != "2026-01-10T00:00:00-08:00" || got.TimeMax != "2026-01-11T00:00:00-08:00" {
124+
t.Fatalf("expected original RFC3339 bounds, got %q -> %q", got.TimeMin, got.TimeMax)
125+
}
126+
}
127+
128+
func TestCalendarFreeBusyCmd_RelativeRangePayload(t *testing.T) {
129+
var got struct {
130+
TimeMin string `json:"timeMin"`
131+
TimeMax string `json:"timeMax"`
132+
Items []struct {
133+
ID string `json:"id"`
134+
} `json:"items"`
135+
}
136+
srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
137+
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
138+
switch {
139+
case r.Method == http.MethodPost && strings.Contains(path, "/freeBusy"):
140+
_ = json.NewDecoder(r.Body).Decode(&got)
141+
w.Header().Set("Content-Type", "application/json")
142+
_ = json.NewEncoder(w).Encode(map[string]any{
143+
"calendars": map[string]any{
144+
"[email protected]": map[string]any{"busy": []map[string]any{}},
145+
},
146+
})
147+
default:
148+
http.NotFound(w, r)
149+
}
150+
})))
151+
defer srv.Close()
152+
153+
svc, err := calendar.NewService(context.Background(),
154+
option.WithoutAuthentication(),
155+
option.WithHTTPClient(srv.Client()),
156+
option.WithEndpoint(srv.URL+"/"),
157+
)
158+
if err != nil {
159+
t.Fatalf("NewService: %v", err)
160+
}
161+
result := executeWithCalendarTestService(t, []string{
162+
"--json",
163+
"--account", "[email protected]",
164+
"calendar", "freebusy",
165+
166+
"--from", calendarExprToday,
167+
"--to", calendarExprTomorrow,
168+
}, svc)
169+
if result.err != nil {
170+
t.Fatalf("Execute: %v", result.err)
171+
}
172+
173+
from, err := time.Parse(time.RFC3339, got.TimeMin)
174+
if err != nil {
175+
t.Fatalf("timeMin is not RFC3339: %q: %v", got.TimeMin, err)
176+
}
177+
to, err := time.Parse(time.RFC3339, got.TimeMax)
178+
if err != nil {
179+
t.Fatalf("timeMax is not RFC3339: %q: %v", got.TimeMax, err)
180+
}
181+
if got.TimeMin == calendarExprToday || got.TimeMax == calendarExprTomorrow {
182+
t.Fatalf("expected parsed RFC3339 values, got timeMin=%q timeMax=%q", got.TimeMin, got.TimeMax)
183+
}
184+
if from.Location() != time.UTC || to.Location() != time.UTC {
185+
t.Fatalf("expected UTC payload values, got %v -> %v", from.Location(), to.Location())
186+
}
187+
if from.Hour() != 0 || from.Minute() != 0 || from.Second() != 0 || to.Hour() != 0 || to.Minute() != 0 || to.Second() != 0 {
188+
t.Fatalf("expected whole-day bounds, got %s -> %s", got.TimeMin, got.TimeMax)
189+
}
190+
if to.Sub(from) != 48*time.Hour {
191+
t.Fatalf("expected today through tomorrow range, got %s -> %s", got.TimeMin, got.TimeMax)
192+
}
193+
if len(got.Items) != 1 || got.Items[0].ID != "[email protected]" {
194+
t.Fatalf("expected work calendar item, got %#v", got.Items)
195+
}
196+
}
197+
76198
func TestCalendarConflictsCmd_AllCalendarsSelection(t *testing.T) {
77199
var gotIDs []string
78200
srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

internal/cmd/execute_more_text_coverage_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ func TestExecute_ContactsGet_CustomFieldsSorted_Text(t *testing.T) {
139139
}
140140

141141
func TestExecute_CalendarFreeBusy_Text(t *testing.T) {
142-
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
142+
srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
143143
if !(strings.Contains(r.URL.Path, "/freeBusy") && r.Method == http.MethodPost) {
144144
http.NotFound(w, r)
145145
return
@@ -152,7 +152,7 @@ func TestExecute_CalendarFreeBusy_Text(t *testing.T) {
152152
},
153153
},
154154
})
155-
}))
155+
})))
156156
defer srv.Close()
157157

158158
svc, err := calendar.NewService(context.Background(),

internal/cmd/help_printer_test.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,30 @@ func TestHelpProfileNoColorEnv(t *testing.T) {
128128
}
129129

130130
func TestHelpProfileAlways(t *testing.T) {
131-
orig := os.Getenv("NO_COLOR")
132-
t.Cleanup(func() { _ = os.Setenv("NO_COLOR", orig) })
131+
orig, hadOrig := os.LookupEnv("NO_COLOR")
132+
origCLIColor, hadCLIColor := os.LookupEnv("CLICOLOR")
133+
origCLIColorForce, hadCLIColorForce := os.LookupEnv("CLICOLOR_FORCE")
134+
t.Cleanup(func() {
135+
if hadOrig {
136+
_ = os.Setenv("NO_COLOR", orig)
137+
} else {
138+
_ = os.Unsetenv("NO_COLOR")
139+
}
140+
if hadCLIColor {
141+
_ = os.Setenv("CLICOLOR", origCLIColor)
142+
} else {
143+
_ = os.Unsetenv("CLICOLOR")
144+
}
145+
if hadCLIColorForce {
146+
_ = os.Setenv("CLICOLOR_FORCE", origCLIColorForce)
147+
} else {
148+
_ = os.Unsetenv("CLICOLOR_FORCE")
149+
}
150+
})
133151

134-
_ = os.Setenv("NO_COLOR", "")
152+
_ = os.Unsetenv("NO_COLOR")
153+
_ = os.Unsetenv("CLICOLOR")
154+
_ = os.Unsetenv("CLICOLOR_FORCE")
135155
if got := helpProfile(io.Discard, "always"); got != termenv.TrueColor {
136156
t.Fatalf("expected truecolor profile")
137157
}

internal/cmd/time_helpers.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ type TimeRange struct {
3333
Location *time.Location
3434
}
3535

36+
const (
37+
calendarExprToday = "today"
38+
calendarExprTomorrow = "tomorrow"
39+
calendarExprYesterday = "yesterday"
40+
)
41+
3642
// getCalendarLocation fetches a calendar's timezone and returns it as a location.
3743
// Uses Calendars.Get (not CalendarList.Get) so it works for service accounts
3844
// whose "primary" calendar may not appear in their CalendarList.
@@ -234,7 +240,7 @@ func isDayExpr(expr string, now time.Time, loc *time.Location) bool {
234240
}
235241
exprLower := strings.ToLower(expr)
236242
switch exprLower {
237-
case "today", "tomorrow", "yesterday":
243+
case calendarExprToday, calendarExprTomorrow, calendarExprYesterday:
238244
return true
239245
case "now":
240246
return false

0 commit comments

Comments
 (0)