Skip to content

Commit 9977c0b

Browse files
salmonumbrellaclaudesteipete
authored
fix(gmail/calendar): ISO-2022-JP decoding, cc/bcc headers, calendar selection (#131)
* fix(gmail): decode ISO-2022-JP bodies * fix(gmail): include cc/bcc in get output * feat(calendar): allow selecting calendars in events * test(gmail): add edge case tests for ISO-2022-JP decoding Add tests for edge cases in ISO-2022-JP body decoding: - Mixed ASCII and Japanese text (e.g., "Hello こんにちは World") - Empty content with ISO-2022-JP charset header - Malformed ISO-2022-JP sequences (graceful degradation) - Truncated escape sequences These tests verify the graceful fallback behavior in decodeBodyCharset which returns original data if decoding fails. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(calendar): validate unknown calendar names in resolveCalendarIDs When a calendar name doesn't match any known calendar (not in bySummary or byID maps), return an error listing the unrecognized names instead of treating them as raw calendar IDs which causes cryptic Google API errors. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(calendar): validate unknown and ambiguous calendar name resolutions --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
1 parent 04f6ff2 commit 9977c0b

11 files changed

Lines changed: 607 additions & 42 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
### Fixed
1111
- Calendar: respond patches only attendees to avoid custom reminders validation errors. (#265) — thanks @sebasrodriguez.
1212
- Secrets: respect empty `GOG_KEYRING_PASSWORD` (treat set-to-empty as intentional; avoids headless prompts). (#269) — thanks @zerone0x.
13+
- Calendar: reject ambiguous calendar-name selectors for `calendar events` instead of guessing. (#131) — thanks @salmonumbrella.
1314

1415
## 0.11.0 - 2026-02-15
1516

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,8 @@ gog calendar events <calendarId> --from today --to friday # Relative dates
678678
gog calendar events <calendarId> --from today --to friday --weekday # Include weekday columns
679679
gog calendar events <calendarId> --from 2025-01-01T00:00:00Z --to 2025-01-08T00:00:00Z
680680
gog calendar events --all # Fetch events from all calendars
681+
gog calendar events --calendars 1,3 # Fetch events from calendar indices (see gog calendar calendars)
682+
gog calendar events --cal Work --cal Personal # Fetch events from calendars by name/ID
681683
gog calendar event <calendarId> <eventId>
682684
gog calendar get <calendarId> <eventId> # Alias for event
683685
gog calendar search "meeting" --today

docs/spec.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ Flag aliases:
194194
- `gog drive drives [--max N] [--page TOKEN] [--query Q]`
195195
- `gog calendar calendars`
196196
- `gog calendar acl <calendarId>`
197-
- `gog calendar events <calendarId> [--from RFC3339] [--to RFC3339] [--max N] [--page TOKEN] [--query Q] [--weekday]`
197+
- `gog calendar events <calendarId> [--cal ID_OR_NAME] [--calendars CSV] [--all] [--from RFC3339] [--to RFC3339] [--max N] [--page TOKEN] [--query Q] [--weekday]`
198198
- `gog calendar event|get <calendarId> <eventId>`
199199
- `GOG_CALENDAR_WEEKDAY=1` defaults `--weekday` for `gog calendar events`
200200
- `gog calendar create <calendarId> --summary S --from DT --to DT [--description D] [--location L] [--attendees a@b.com,c@d.com] [--all-day] [--event-type TYPE]`

internal/cmd/calendar.go

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -196,24 +196,26 @@ func (c *CalendarAclCmd) Run(ctx context.Context, flags *RootFlags) error {
196196
}
197197

198198
type CalendarEventsCmd struct {
199-
CalendarID string `arg:"" name:"calendarId" optional:"" help:"Calendar ID (default: primary)"`
200-
From string `name:"from" help:"Start time (RFC3339, date, or relative: today, tomorrow, monday)"`
201-
To string `name:"to" help:"End time (RFC3339, date, or relative)"`
202-
Today bool `name:"today" help:"Today only (timezone-aware)"`
203-
Tomorrow bool `name:"tomorrow" help:"Tomorrow only (timezone-aware)"`
204-
Week bool `name:"week" help:"This week (uses --week-start, default Mon)"`
205-
Days int `name:"days" help:"Next N days (timezone-aware)" default:"0"`
206-
WeekStart string `name:"week-start" help:"Week start day for --week (sun, mon, ...)" default:""`
207-
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"10"`
208-
Page string `name:"page" aliases:"cursor" help:"Page token"`
209-
AllPages bool `name:"all-pages" aliases:"allpages" help:"Fetch all pages"`
210-
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"`
211-
Query string `name:"query" help:"Free text search"`
212-
All bool `name:"all" help:"Fetch events from all calendars"`
213-
PrivatePropFilter string `name:"private-prop-filter" help:"Filter by private extended property (key=value)"`
214-
SharedPropFilter string `name:"shared-prop-filter" help:"Filter by shared extended property (key=value)"`
215-
Fields string `name:"fields" help:"Comma-separated fields to return"`
216-
Weekday bool `name:"weekday" help:"Include start/end day-of-week columns" default:"${calendar_weekday}"`
199+
CalendarID string `arg:"" name:"calendarId" optional:"" help:"Calendar ID (default: primary)"`
200+
Cal []string `name:"cal" help:"Calendar ID or name (can be repeated)"`
201+
Calendars string `name:"calendars" help:"Comma-separated calendar IDs, names, or indices from 'calendar calendars'"`
202+
From string `name:"from" help:"Start time (RFC3339, date, or relative: today, tomorrow, monday)"`
203+
To string `name:"to" help:"End time (RFC3339, date, or relative)"`
204+
Today bool `name:"today" help:"Today only (timezone-aware)"`
205+
Tomorrow bool `name:"tomorrow" help:"Tomorrow only (timezone-aware)"`
206+
Week bool `name:"week" help:"This week (uses --week-start, default Mon)"`
207+
Days int `name:"days" help:"Next N days (timezone-aware)" default:"0"`
208+
WeekStart string `name:"week-start" help:"Week start day for --week (sun, mon, ...)" default:""`
209+
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"10"`
210+
Page string `name:"page" aliases:"cursor" help:"Page token"`
211+
AllPages bool `name:"all-pages" aliases:"allpages" help:"Fetch all pages"`
212+
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"`
213+
Query string `name:"query" help:"Free text search"`
214+
All bool `name:"all" help:"Fetch events from all calendars"`
215+
PrivatePropFilter string `name:"private-prop-filter" help:"Filter by private extended property (key=value)"`
216+
SharedPropFilter string `name:"shared-prop-filter" help:"Filter by shared extended property (key=value)"`
217+
Fields string `name:"fields" help:"Comma-separated fields to return"`
218+
Weekday bool `name:"weekday" help:"Include start/end day-of-week columns" default:"${calendar_weekday}"`
217219
}
218220

219221
func (c *CalendarEventsCmd) Run(ctx context.Context, flags *RootFlags) error {
@@ -223,10 +225,17 @@ func (c *CalendarEventsCmd) Run(ctx context.Context, flags *RootFlags) error {
223225
}
224226

225227
calendarID := strings.TrimSpace(c.CalendarID)
226-
if c.All && calendarID != "" {
227-
return usage("calendarId not allowed with --all flag")
228+
calInputs := append([]string{}, c.Cal...)
229+
if strings.TrimSpace(c.Calendars) != "" {
230+
calInputs = append(calInputs, splitCSV(c.Calendars)...)
228231
}
229-
if !c.All && calendarID == "" {
232+
if c.All && (calendarID != "" || len(calInputs) > 0) {
233+
return usage("calendarId or --cal/--calendars not allowed with --all flag")
234+
}
235+
if calendarID != "" && len(calInputs) > 0 {
236+
return usage("calendarId not allowed with --cal/--calendars")
237+
}
238+
if !c.All && calendarID == "" && len(calInputs) == 0 {
230239
calendarID = primaryCalendarID
231240
}
232241

@@ -260,6 +269,16 @@ func (c *CalendarEventsCmd) Run(ctx context.Context, flags *RootFlags) error {
260269
if c.All {
261270
return listAllCalendarsEvents(ctx, svc, from, to, c.Max, c.Page, c.AllPages, c.FailEmpty, c.Query, c.PrivatePropFilter, c.SharedPropFilter, c.Fields, c.Weekday)
262271
}
272+
if len(calInputs) > 0 {
273+
ids, err := resolveCalendarIDs(ctx, svc, calInputs)
274+
if err != nil {
275+
return err
276+
}
277+
if len(ids) == 0 {
278+
return usage("no calendars specified")
279+
}
280+
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)
281+
}
263282
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)
264283
}
265284

internal/cmd/calendar_events_test.go

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ package cmd
33
import (
44
"context"
55
"encoding/json"
6+
"errors"
67
"io"
78
"net/http"
89
"net/http/httptest"
910
"strings"
11+
"sync"
1012
"testing"
1113

1214
"google.golang.org/api/calendar/v3"
@@ -115,3 +117,246 @@ func TestCalendarEventsCmd_DefaultsToPrimary(t *testing.T) {
115117
t.Fatalf("unexpected output: %q", out)
116118
}
117119
}
120+
121+
func TestCalendarEventsCmd_CalendarsFlag(t *testing.T) {
122+
origNew := newCalendarService
123+
t.Cleanup(func() { newCalendarService = origNew })
124+
125+
var mu sync.Mutex
126+
calls := make(map[string]int)
127+
128+
srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
129+
switch {
130+
case strings.Contains(r.URL.Path, "/calendarList") &&
131+
!strings.Contains(r.URL.Path, "/calendarList/primary") &&
132+
r.Method == http.MethodGet:
133+
w.Header().Set("Content-Type", "application/json")
134+
_ = json.NewEncoder(w).Encode(map[string]any{
135+
"items": []map[string]any{
136+
{"id": "c1", "summary": "Work"},
137+
{"id": "c2", "summary": "Family"},
138+
{"id": "c3", "summary": "Other"},
139+
},
140+
})
141+
return
142+
case strings.Contains(r.URL.Path, "/calendars/c1/events") && r.Method == http.MethodGet:
143+
mu.Lock()
144+
calls["c1"]++
145+
mu.Unlock()
146+
w.Header().Set("Content-Type", "application/json")
147+
_ = json.NewEncoder(w).Encode(map[string]any{
148+
"items": []map[string]any{
149+
{"id": "e1", "summary": "Event 1"},
150+
},
151+
})
152+
return
153+
case strings.Contains(r.URL.Path, "/calendars/c2/events") && r.Method == http.MethodGet:
154+
mu.Lock()
155+
calls["c2"]++
156+
mu.Unlock()
157+
w.Header().Set("Content-Type", "application/json")
158+
_ = json.NewEncoder(w).Encode(map[string]any{
159+
"items": []map[string]any{
160+
{"id": "e2", "summary": "Event 2"},
161+
},
162+
})
163+
return
164+
case strings.Contains(r.URL.Path, "/calendars/c3/events") && r.Method == http.MethodGet:
165+
mu.Lock()
166+
calls["c3"]++
167+
mu.Unlock()
168+
http.Error(w, "unexpected calendar", http.StatusBadRequest)
169+
return
170+
default:
171+
http.NotFound(w, r)
172+
return
173+
}
174+
})))
175+
defer srv.Close()
176+
177+
svc, err := calendar.NewService(context.Background(),
178+
option.WithoutAuthentication(),
179+
option.WithHTTPClient(srv.Client()),
180+
option.WithEndpoint(srv.URL+"/"),
181+
)
182+
if err != nil {
183+
t.Fatalf("NewService: %v", err)
184+
}
185+
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
186+
187+
u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"})
188+
if err != nil {
189+
t.Fatalf("ui.New: %v", err)
190+
}
191+
ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true})
192+
flags := &RootFlags{Account: "a@b.com"}
193+
194+
cmd := &CalendarEventsCmd{
195+
Calendars: "1,Family",
196+
From: "2025-01-01T00:00:00Z",
197+
To: "2025-01-02T00:00:00Z",
198+
}
199+
out := captureStdout(t, func() {
200+
if err := cmd.Run(ctx, flags); err != nil {
201+
t.Fatalf("Run: %v", err)
202+
}
203+
})
204+
205+
var parsed struct {
206+
Events []map[string]any `json:"events"`
207+
}
208+
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
209+
t.Fatalf("json parse: %v", err)
210+
}
211+
if len(parsed.Events) != 2 {
212+
t.Fatalf("unexpected events: %#v", parsed.Events)
213+
}
214+
215+
mu.Lock()
216+
defer mu.Unlock()
217+
if calls["c1"] == 0 || calls["c2"] == 0 || calls["c3"] != 0 {
218+
t.Fatalf("unexpected calendar calls: %#v", calls)
219+
}
220+
}
221+
222+
func TestResolveCalendarIDs_IndexOutOfRange(t *testing.T) {
223+
srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
224+
if strings.Contains(r.URL.Path, "/calendarList") &&
225+
!strings.Contains(r.URL.Path, "/calendarList/primary") &&
226+
r.Method == http.MethodGet {
227+
w.Header().Set("Content-Type", "application/json")
228+
_ = json.NewEncoder(w).Encode(map[string]any{
229+
"items": []map[string]any{
230+
{"id": "c1", "summary": "Work"},
231+
},
232+
})
233+
return
234+
}
235+
http.NotFound(w, r)
236+
})))
237+
defer srv.Close()
238+
239+
svc, err := calendar.NewService(context.Background(),
240+
option.WithoutAuthentication(),
241+
option.WithHTTPClient(srv.Client()),
242+
option.WithEndpoint(srv.URL+"/"),
243+
)
244+
if err != nil {
245+
t.Fatalf("NewService: %v", err)
246+
}
247+
248+
_, err = resolveCalendarIDs(context.Background(), svc, []string{"2"})
249+
if err == nil {
250+
t.Fatalf("expected error")
251+
}
252+
var ee *ExitError
253+
if !errors.As(err, &ee) || ee.Code != 2 {
254+
t.Fatalf("expected usage error, got %v", err)
255+
}
256+
}
257+
258+
func TestResolveCalendarIDs_AmbiguousName(t *testing.T) {
259+
srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
260+
if strings.Contains(r.URL.Path, "/calendarList") &&
261+
!strings.Contains(r.URL.Path, "/calendarList/primary") &&
262+
r.Method == http.MethodGet {
263+
w.Header().Set("Content-Type", "application/json")
264+
_ = json.NewEncoder(w).Encode(map[string]any{
265+
"items": []map[string]any{
266+
{"id": "c1", "summary": "Work"},
267+
{"id": "c2", "summary": "Work"},
268+
{"id": "c3", "summary": "Family"},
269+
},
270+
})
271+
return
272+
}
273+
http.NotFound(w, r)
274+
})))
275+
defer srv.Close()
276+
277+
svc, err := calendar.NewService(context.Background(),
278+
option.WithoutAuthentication(),
279+
option.WithHTTPClient(srv.Client()),
280+
option.WithEndpoint(srv.URL+"/"),
281+
)
282+
if err != nil {
283+
t.Fatalf("NewService: %v", err)
284+
}
285+
286+
_, err = resolveCalendarIDs(context.Background(), svc, []string{"Work"})
287+
if err == nil {
288+
t.Fatalf("expected error")
289+
}
290+
var ee *ExitError
291+
if !errors.As(err, &ee) || ee.Code != 2 {
292+
t.Fatalf("expected usage error, got %v", err)
293+
}
294+
if !strings.Contains(err.Error(), "ambiguous") {
295+
t.Fatalf("expected ambiguous error, got %v", err)
296+
}
297+
}
298+
299+
func TestResolveCalendarIDs_UnrecognizedName(t *testing.T) {
300+
srv := httptest.NewServer(withPrimaryCalendar(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
301+
if strings.Contains(r.URL.Path, "/calendarList") &&
302+
!strings.Contains(r.URL.Path, "/calendarList/primary") &&
303+
r.Method == http.MethodGet {
304+
w.Header().Set("Content-Type", "application/json")
305+
_ = json.NewEncoder(w).Encode(map[string]any{
306+
"items": []map[string]any{
307+
{"id": "c1", "summary": "Work"},
308+
{"id": "c2", "summary": "Family"},
309+
},
310+
})
311+
return
312+
}
313+
http.NotFound(w, r)
314+
})))
315+
defer srv.Close()
316+
317+
svc, err := calendar.NewService(context.Background(),
318+
option.WithoutAuthentication(),
319+
option.WithHTTPClient(srv.Client()),
320+
option.WithEndpoint(srv.URL+"/"),
321+
)
322+
if err != nil {
323+
t.Fatalf("NewService: %v", err)
324+
}
325+
326+
// Test single unrecognized name
327+
_, err = resolveCalendarIDs(context.Background(), svc, []string{"NonExistent"})
328+
if err == nil {
329+
t.Fatalf("expected error for unrecognized calendar name")
330+
}
331+
var ee *ExitError
332+
if !errors.As(err, &ee) || ee.Code != 2 {
333+
t.Fatalf("expected usage error, got %v", err)
334+
}
335+
if !strings.Contains(err.Error(), "unrecognized calendar name(s)") {
336+
t.Fatalf("expected error message to mention unrecognized calendar, got: %v", err)
337+
}
338+
if !strings.Contains(err.Error(), "NonExistent") {
339+
t.Fatalf("expected error message to include the unrecognized name, got: %v", err)
340+
}
341+
342+
// Test multiple unrecognized names
343+
_, err = resolveCalendarIDs(context.Background(), svc, []string{"Work", "Unknown1", "Unknown2"})
344+
if err == nil {
345+
t.Fatalf("expected error for unrecognized calendar names")
346+
}
347+
if !errors.As(err, &ee) || ee.Code != 2 {
348+
t.Fatalf("expected usage error, got %v", err)
349+
}
350+
if !strings.Contains(err.Error(), "Unknown1") || !strings.Contains(err.Error(), "Unknown2") {
351+
t.Fatalf("expected error message to include all unrecognized names, got: %v", err)
352+
}
353+
354+
// Test valid names still work
355+
ids, err := resolveCalendarIDs(context.Background(), svc, []string{"Work", "Family"})
356+
if err != nil {
357+
t.Fatalf("unexpected error for valid calendar names: %v", err)
358+
}
359+
if len(ids) != 2 || ids[0] != "c1" || ids[1] != "c2" {
360+
t.Fatalf("unexpected ids: %v", ids)
361+
}
362+
}

0 commit comments

Comments
 (0)