Skip to content

Commit b35481e

Browse files
malobclaudesteipete
authored
feat(calendar): add --timezone flag to event create (#844)
* feat(calendar): add --timezone flag to event create Apply one IANA timezone to both --from and --to via --timezone/--tz, mutually exclusive with the granular --start-timezone/--end-timezone flags. Mirrors the existing --timezone/--tz on `calendar create-calendar`. Adds resolveUnifiedTimezone (mutex + set-both), which returns the flag name so invalid-zone/all-day errors attribute to the flag the user set. Includes unit + command tests, regenerated command docs, and a changelog entry. Co-Authored-By: Claude Opus 4.8 <[email protected]> * docs(changelog): note calendar timezone flag --------- Co-authored-by: Claude Opus 4.8 <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent c172741 commit b35481e

8 files changed

Lines changed: 189 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
### Added
66

7+
- Calendar: add `create --timezone`/`--tz` to apply one IANA timezone to both event endpoints while retaining granular start/end timezone flags. (#844) — thanks @malob.
78
- Docs: add non-destructive `insert-image --before` and `--after` anchors while clarifying that `--at` replaces its placeholder. (#839) — thanks @sebsnyk.
89
- Slides: allow `insert-image` and `replace-slide` to use public HTTPS image URLs without temporary Drive sharing. (#825) — thanks @sebsnyk.
910
- Slides: add structured element geometry, styled text runs, table-cell content, image source URLs, native presentation metadata, and read-only text location with exact UTF-16 ranges. (#822) — thanks @sebsnyk.

docs/commands/gog-calendar-create.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ gog calendar (cal) create (add,new) <calendarId> [flags]
6666
| `--source-url` | `string` | | URL where event was created/imported from |
6767
| `--start-timezone`<br>`--from-timezone` | `string` | | IANA timezone metadata for --from (e.g., Europe/Rome) |
6868
| `--summary` | `string` | | Event summary/title |
69+
| `--timezone`<br>`--tz` | `string` | | IANA timezone metadata applied to both --from and --to (e.g., America/Los_Angeles); mutually exclusive with --start-timezone/--end-timezone |
6970
| `--to` | `string` | | End time (RFC3339) |
7071
| `--transparency` | `string` | | Show as busy (opaque) or free (transparent). Aliases: busy, free |
7172
| `-v`<br>`--verbose` | `bool` | | Enable verbose logging |

docs/spec.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ Drive hierarchy semantics:
274274
- `gog calendar events <calendarId> [--cal ID_OR_NAME] [--calendars CSV] [--all] [--from RFC3339] [--to RFC3339] [--max N] [--page TOKEN] [--query Q] [--weekday]`
275275
- `gog calendar event|get <calendarId> <eventId>`
276276
- `GOG_CALENDAR_WEEKDAY=1` defaults `--weekday` for `gog calendar events`
277-
- `gog calendar create <calendarId> --summary S --from DT --to DT [--start-timezone TZ] [--end-timezone TZ] [--description D] [--location L|--location-search Q|--place-id ID] [--place-language LANG] [--place-region REGION] [--attendees [email protected],[email protected]] [--all-day] [--event-type TYPE]`
277+
- `gog calendar create <calendarId> --summary S --from DT --to DT [--timezone TZ] [--start-timezone TZ] [--end-timezone TZ] [--description D] [--location L|--location-search Q|--place-id ID] [--place-language LANG] [--place-region REGION] [--attendees [email protected],[email protected]] [--all-day] [--event-type TYPE]`
278278
- `gog calendar update <calendarId> <eventId> [--summary S] [--from DT] [--to DT] [--start-timezone TZ] [--end-timezone TZ] [--description D] [--location L|--location-search Q|--place-id ID] [--place-language LANG] [--place-region REGION] [--attendees ...] [--add-attendee ...] [--attachment URL ...] [--all-day] [--with-meet|--regenerate-meet] [--event-type TYPE]`
279279
- `gog calendar delete <calendarId> <eventId>`
280280
- `gog calendar freebusy [calendarIds] [--cal ID_OR_NAME] [--calendars CSV] [--all] --from RFC3339 --to RFC3339`

internal/cmd/calendar_build.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,30 @@ func buildEventDateTimeWithTimezone(value string, allDay bool, timezone, flagNam
4444
return edt, nil
4545
}
4646

47+
// Flag names for the event start/end timezone options. Shared so the unified
48+
// --timezone resolution can attribute validation errors to the flag the user
49+
// actually set.
50+
const (
51+
flagStartTimezone = "--start-timezone"
52+
flagEndTimezone = "--end-timezone"
53+
flagTimezone = "--timezone"
54+
)
55+
56+
// resolveUnifiedTimezone applies a single --timezone/--tz value to both the
57+
// start and end timezone. It is mutually exclusive with the granular
58+
// --start-timezone/--end-timezone flags: supplying --timezone alongside either
59+
// is a usage error. The returned flag names let downstream validation errors
60+
// (invalid zone, all-day) point at whichever flag the user actually set.
61+
func resolveUnifiedTimezone(unified, start, end string) (startTZ, startFlag, endTZ, endFlag string, err error) {
62+
if strings.TrimSpace(unified) == "" {
63+
return start, flagStartTimezone, end, flagEndTimezone, nil
64+
}
65+
if strings.TrimSpace(start) != "" || strings.TrimSpace(end) != "" {
66+
return "", "", "", "", usage("--timezone cannot be combined with --start-timezone or --end-timezone")
67+
}
68+
return unified, flagTimezone, unified, flagTimezone, nil
69+
}
70+
4771
func etcGMTForOffsetSeconds(offset int) (string, bool) {
4872
if offset == 0 || offset%3600 != 0 {
4973
return "", false

internal/cmd/calendar_build_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,41 @@ func TestBuildExtendedProperties(t *testing.T) {
9494
t.Fatalf("unexpected shared props: %#v", props.Shared)
9595
}
9696
}
97+
98+
func TestResolveUnifiedTimezone(t *testing.T) {
99+
// Unset --timezone: pass the granular values through unchanged.
100+
startTZ, startFlag, endTZ, endFlag, err := resolveUnifiedTimezone("", "Europe/Rome", "America/New_York")
101+
if err != nil {
102+
t.Fatalf("unexpected error: %v", err)
103+
}
104+
if startTZ != "Europe/Rome" || endTZ != "America/New_York" {
105+
t.Fatalf("passthrough changed values: start=%q end=%q", startTZ, endTZ)
106+
}
107+
if startFlag != "--start-timezone" || endFlag != "--end-timezone" {
108+
t.Fatalf("unexpected flag names: %q %q", startFlag, endFlag)
109+
}
110+
111+
// --timezone alone: apply it to both endpoints with --timezone error attribution.
112+
startTZ, startFlag, endTZ, endFlag, err = resolveUnifiedTimezone("America/Los_Angeles", "", "")
113+
if err != nil {
114+
t.Fatalf("unexpected error: %v", err)
115+
}
116+
if startTZ != "America/Los_Angeles" || endTZ != "America/Los_Angeles" {
117+
t.Fatalf("expected both zones set, got start=%q end=%q", startTZ, endTZ)
118+
}
119+
if startFlag != "--timezone" || endFlag != "--timezone" {
120+
t.Fatalf("expected --timezone flag names, got %q %q", startFlag, endFlag)
121+
}
122+
123+
// --timezone combined with --start-timezone is a usage error.
124+
if _, _, _, _, err := resolveUnifiedTimezone("America/Los_Angeles", "Europe/Rome", ""); err == nil {
125+
t.Fatalf("expected error combining --timezone with --start-timezone")
126+
} else if got := ExitCode(err); got != 2 {
127+
t.Fatalf("expected usage exit code 2, got %d (err=%v)", got, err)
128+
}
129+
130+
// --timezone combined with --end-timezone is a usage error.
131+
if _, _, _, _, err := resolveUnifiedTimezone("America/Los_Angeles", "", "America/New_York"); err == nil {
132+
t.Fatalf("expected error combining --timezone with --end-timezone")
133+
}
134+
}

internal/cmd/calendar_create_update_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,121 @@ func TestCalendarCreateCmd_WithMeetAndAttachments(t *testing.T) {
112112
}
113113
}
114114

115+
func TestCalendarCreateCmd_UnifiedTimezoneSetsBoth(t *testing.T) {
116+
var gotEvent calendar.Event
117+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118+
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
119+
if r.Method == http.MethodPost && path == "/calendars/[email protected]/events" {
120+
_ = json.NewDecoder(r.Body).Decode(&gotEvent)
121+
w.Header().Set("Content-Type", "application/json")
122+
_ = json.NewEncoder(w).Encode(map[string]any{"id": "ev1"})
123+
return
124+
}
125+
http.NotFound(w, r)
126+
}))
127+
defer srv.Close()
128+
129+
svc := newCalendarServiceFromServer(t, srv)
130+
ctx := withCalendarTestService(newCmdRuntimeJSONOutputContext(t, io.Discard, io.Discard), svc)
131+
132+
cmd := &CalendarCreateCmd{}
133+
if err := runKong(t, cmd, []string{
134+
135+
"--summary", "Meeting",
136+
"--from", "2026-08-13T13:40:00-07:00",
137+
"--to", "2026-08-13T14:40:00-07:00",
138+
"--tz", "America/Los_Angeles",
139+
}, ctx, &RootFlags{Account: "[email protected]"}); err != nil {
140+
t.Fatalf("runKong: %v", err)
141+
}
142+
if gotEvent.Start == nil || gotEvent.End == nil {
143+
t.Fatalf("missing start/end: %#v", gotEvent)
144+
}
145+
if gotEvent.Start.TimeZone != "America/Los_Angeles" || gotEvent.End.TimeZone != "America/Los_Angeles" {
146+
t.Fatalf("expected both zones America/Los_Angeles, got start=%q end=%q", gotEvent.Start.TimeZone, gotEvent.End.TimeZone)
147+
}
148+
}
149+
150+
func TestCalendarCreateCmd_UnifiedTimezoneConflict(t *testing.T) {
151+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
152+
http.NotFound(w, r)
153+
}))
154+
defer srv.Close()
155+
156+
svc := newCalendarServiceFromServer(t, srv)
157+
ctx := withCalendarTestService(newCmdRuntimeJSONOutputContext(t, io.Discard, io.Discard), svc)
158+
159+
cmd := &CalendarCreateCmd{}
160+
err := runKong(t, cmd, []string{
161+
162+
"--summary", "Meeting",
163+
"--from", "2026-08-13T13:40:00-07:00",
164+
"--to", "2026-08-13T14:40:00-07:00",
165+
"--timezone", "America/Los_Angeles",
166+
"--start-timezone", "Europe/Rome",
167+
}, ctx, &RootFlags{Account: "[email protected]"})
168+
if err == nil {
169+
t.Fatalf("expected error combining --timezone with --start-timezone")
170+
}
171+
if got := ExitCode(err); got != 2 {
172+
t.Fatalf("expected usage exit code 2, got %d (err=%v)", got, err)
173+
}
174+
}
175+
176+
func TestCalendarCreateCmd_UnifiedTimezoneInvalidZoneAttributesFlag(t *testing.T) {
177+
cases := []struct {
178+
name string
179+
args []string
180+
}{
181+
{
182+
name: "invalid zone",
183+
args: []string{
184+
185+
"--summary", "Meeting",
186+
"--from", "2026-08-13T13:40:00-07:00",
187+
"--to", "2026-08-13T14:40:00-07:00",
188+
"--timezone", "Nope/Zone",
189+
},
190+
},
191+
{
192+
name: "all-day rejects timezone",
193+
args: []string{
194+
195+
"--summary", "Meeting",
196+
"--all-day",
197+
"--from", "2026-08-13",
198+
"--to", "2026-08-14",
199+
"--timezone", "America/Los_Angeles",
200+
},
201+
},
202+
}
203+
for _, tc := range cases {
204+
t.Run(tc.name, func(t *testing.T) {
205+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
206+
http.NotFound(w, r)
207+
}))
208+
defer srv.Close()
209+
210+
svc := newCalendarServiceFromServer(t, srv)
211+
ctx := withCalendarTestService(newCmdRuntimeJSONOutputContext(t, io.Discard, io.Discard), svc)
212+
213+
cmd := &CalendarCreateCmd{}
214+
err := runKong(t, cmd, tc.args, ctx, &RootFlags{Account: "[email protected]"})
215+
if err == nil {
216+
t.Fatalf("expected error, got nil")
217+
}
218+
if got := ExitCode(err); got != 2 {
219+
t.Fatalf("expected usage exit code 2, got %d (err=%v)", got, err)
220+
}
221+
// The whole point of the flag-name plumbing: the error must name
222+
// --timezone, not the granular --start-timezone the user didn't use.
223+
if msg := err.Error(); !strings.Contains(msg, "--timezone") || strings.Contains(msg, "--start-timezone") {
224+
t.Fatalf("expected error to attribute to --timezone, got: %q", msg)
225+
}
226+
})
227+
}
228+
}
229+
115230
func TestCalendarCreateCmd_RecurringOffsetTimezoneFallback(t *testing.T) {
116231
var gotEvent calendar.Event
117232
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

internal/cmd/calendar_edit.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type CalendarCreateCmd struct {
2222
To string `name:"to" help:"End time (RFC3339)"`
2323
StartTimezone string `name:"start-timezone" aliases:"from-timezone" help:"IANA timezone metadata for --from (e.g., Europe/Rome)"`
2424
EndTimezone string `name:"end-timezone" aliases:"to-timezone" help:"IANA timezone metadata for --to (e.g., America/New_York)"`
25+
Timezone string `name:"timezone" aliases:"tz" help:"IANA timezone metadata applied to both --from and --to (e.g., America/Los_Angeles); mutually exclusive with --start-timezone/--end-timezone"`
2526
Description string `name:"description" help:"Description"`
2627
Location string `name:"location" help:"Location"`
2728
LocationSearch string `name:"location-search" help:"Resolve a Google Places text search and use the best match as event location"`
@@ -80,6 +81,7 @@ func calendarCreateInputFromCommand(c *CalendarCreateCmd) calendarCreateInput {
8081
To: c.To,
8182
StartTimezone: c.StartTimezone,
8283
EndTimezone: c.EndTimezone,
84+
Timezone: c.Timezone,
8385
Description: c.Description,
8486
Location: c.Location,
8587
Attendees: c.Attendees,

internal/cmd/calendar_event_plan.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ type calendarCreateInput struct {
3535
To string
3636
StartTimezone string
3737
EndTimezone string
38+
Timezone string
3839
Description string
3940
Location string
4041
Attendees string
@@ -358,11 +359,15 @@ func buildCalendarCreatePlan(store *config.ConfigStore, input calendarCreateInpu
358359
if err != nil {
359360
return nil, err
360361
}
361-
start, err := buildEventDateTimeWithTimezone(input.From, allDay, input.StartTimezone, "--start-timezone")
362+
startTZ, startTZFlag, endTZ, endTZFlag, err := resolveUnifiedTimezone(input.Timezone, input.StartTimezone, input.EndTimezone)
362363
if err != nil {
363364
return nil, err
364365
}
365-
end, err := buildEventDateTimeWithTimezone(input.To, allDay, input.EndTimezone, "--end-timezone")
366+
start, err := buildEventDateTimeWithTimezone(input.From, allDay, startTZ, startTZFlag)
367+
if err != nil {
368+
return nil, err
369+
}
370+
end, err := buildEventDateTimeWithTimezone(input.To, allDay, endTZ, endTZFlag)
366371
if err != nil {
367372
return nil, err
368373
}

0 commit comments

Comments
 (0)