Skip to content

Commit cccc5e1

Browse files
malobclaude
andcommitted
feat(calendar): add --event-types filter to calendar events
Add an opt-in --event-types flag to `calendar events`, passed through to the Calendar API events.list eventTypes parameter. It accepts the six API event types plus friendly aliases (focus-time, ooo, working-location/wl, from-gmail, ...), repeatable or comma-separated, canonicalized and deduplicated, and is honored across single-calendar, --cal/--calendars, and --all paths. Without a filter, events.list returns every event type, so birthday, workingLocation, and fromGmail entries are interleaved with real meetings in a "what's on my calendar" view. When the flag is omitted no eventTypes param is sent, so the default (all types) is unchanged; a flag that is present but resolves to no values is a usage error. Event-type aliases now live in a single eventTypeAliases map shared by the create/update path (restricted to the user-creatable types) and the events.list filter (which accepts all six, including the read-only birthday and fromGmail). Tests cover both normalizers, the resolver, the shared-map invariant, and that the eventTypes query param reaches the API when set (single-calendar and across the --cal/--all paths) and is absent when unset. Command docs regenerated. Co-Authored-By: Claude Opus 4.8 <[email protected]>
1 parent fc13b41 commit cccc5e1

10 files changed

Lines changed: 358 additions & 35 deletions

docs/commands/gog-calendar-events.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ gog calendar (cal) events (list,ls) [<calendarId> ...] [flags]
3131
| `-n`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully |
3232
| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) |
3333
| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children |
34+
| `--event-types` | `[]string` | | Filter to event types (repeatable or comma-separated): default, birthday, focus-time, from-gmail, out-of-office, working-location |
3435
| `--fail-empty`<br>`--non-empty`<br>`--require-results` | `bool` | | Exit with code 3 if no results |
3536
| `--fields` | `string` | | Comma-separated fields to return |
3637
| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands |

docs/spec.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,8 @@ Drive hierarchy semantics:
273273
- `gog calendar create-calendar <summary> [--description D] [--timezone TZ] [--location L]`
274274
- `gog calendar delete-calendar <ownedSecondaryCalendarId>`
275275
- `gog calendar acl <calendarId>`
276-
- `gog calendar events <calendarId> [--cal ID_OR_NAME] [--calendars CSV] [--all] [--from RFC3339] [--to RFC3339] [--max N] [--page TOKEN] [--query Q] [--weekday]`
276+
- `gog calendar events <calendarId> [--cal ID_OR_NAME] [--calendars CSV] [--all] [--from RFC3339] [--to RFC3339] [--max N] [--page TOKEN] [--query Q] [--event-types TYPES] [--weekday]`
277+
- `--event-types` filters to one or more event types (repeatable or comma-separated): `default`, `birthday`, `focus-time`, `from-gmail`, `out-of-office`, `working-location`. Unset returns all types (the API default).
277278
- `gog calendar event|get <calendarId> <eventId>`
278279
- `GOG_CALENDAR_WEEKDAY=1` defaults `--weekday` for `gog calendar events`
279280
- `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]`

internal/cmd/calendar_all_events_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ func TestListAllCalendarsEvents_JSON(t *testing.T) {
6363
ctx := newCmdJSONContext(t)
6464

6565
jsonOut := captureStdout(t, func() {
66-
if runErr := listAllCalendarsEvents(ctx, svc, "2025-01-01T00:00:00Z", "2025-01-02T00:00:00Z", 10, "", false, false, "", "", "", "", false, false, "", ""); runErr != nil {
66+
if runErr := listAllCalendarsEvents(ctx, svc, "2025-01-01T00:00:00Z", "2025-01-02T00:00:00Z", 10, "", false, false, "", "", "", "", nil, false, false, "", ""); runErr != nil {
6767
t.Fatalf("listAllCalendarsEvents: %v", runErr)
6868
}
6969
})
@@ -126,7 +126,7 @@ func TestListAllCalendarsEvents_SortByStart(t *testing.T) {
126126

127127
ctx := newCmdJSONContext(t)
128128
jsonOut := captureStdout(t, func() {
129-
if err := listAllCalendarsEvents(ctx, svc, "2025-01-01T00:00:00Z", "2025-01-02T00:00:00Z", 10, "", false, false, "", "", "", "", false, false, "start", "asc"); err != nil {
129+
if err := listAllCalendarsEvents(ctx, svc, "2025-01-01T00:00:00Z", "2025-01-02T00:00:00Z", 10, "", false, false, "", "", "", "", nil, false, false, "start", "asc"); err != nil {
130130
t.Fatalf("listAllCalendarsEvents: %v", err)
131131
}
132132
})
@@ -153,7 +153,7 @@ func TestListAllCalendarsEvents_SortByStart(t *testing.T) {
153153

154154
// Descending order flips it.
155155
jsonOut = captureStdout(t, func() {
156-
if err := listAllCalendarsEvents(ctx, svc, "2025-01-01T00:00:00Z", "2025-01-02T00:00:00Z", 10, "", false, false, "", "", "", "", false, false, "start", "desc"); err != nil {
156+
if err := listAllCalendarsEvents(ctx, svc, "2025-01-01T00:00:00Z", "2025-01-02T00:00:00Z", 10, "", false, false, "", "", "", "", nil, false, false, "start", "desc"); err != nil {
157157
t.Fatalf("listAllCalendarsEvents desc: %v", err)
158158
}
159159
})

internal/cmd/calendar_event_type.go

Lines changed: 87 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import (
66

77
const (
88
eventTypeDefault = "default"
9+
eventTypeBirthday = "birthday"
910
eventTypeFocusTime = "focusTime"
11+
eventTypeFromGmail = "fromGmail"
1012
eventTypeOutOfOffice = "outOfOffice"
1113
eventTypeWorkingLocation = "workingLocation"
1214

@@ -18,23 +20,55 @@ const (
1820
defaultOOOAutoDecline = literalAll
1921
)
2022

23+
// eventTypeAliases maps user-supplied event type spellings (canonical and
24+
// friendly aliases, all lowercase) to the canonical Calendar API eventType
25+
// value. It is the single source of truth for both the create/update path
26+
// (which further restricts to creatableEventTypes) and the events.list
27+
// eventTypes filter (which accepts every type the API can return).
28+
var eventTypeAliases = map[string]string{
29+
"default": eventTypeDefault,
30+
"birthday": eventTypeBirthday,
31+
"focus": eventTypeFocusTime,
32+
"focus-time": eventTypeFocusTime,
33+
"focustime": eventTypeFocusTime,
34+
"focus_time": eventTypeFocusTime,
35+
"from-gmail": eventTypeFromGmail,
36+
"fromgmail": eventTypeFromGmail,
37+
"from_gmail": eventTypeFromGmail,
38+
"ooo": eventTypeOutOfOffice,
39+
"out-of-office": eventTypeOutOfOffice,
40+
"outofoffice": eventTypeOutOfOffice,
41+
"out_of_office": eventTypeOutOfOffice,
42+
"wl": eventTypeWorkingLocation,
43+
"working-location": eventTypeWorkingLocation,
44+
"workinglocation": eventTypeWorkingLocation,
45+
"working_location": eventTypeWorkingLocation,
46+
}
47+
48+
// creatableEventTypes are the event types that can be set when creating or
49+
// updating an event. birthday and fromGmail are read-only — they sync from
50+
// Google Contacts and Gmail — so the create/update path rejects them, even
51+
// though they remain valid values for the events.list filter.
52+
var creatableEventTypes = map[string]bool{
53+
eventTypeDefault: true,
54+
eventTypeFocusTime: true,
55+
eventTypeOutOfOffice: true,
56+
eventTypeWorkingLocation: true,
57+
}
58+
59+
// normalizeEventType maps a user-supplied event type to a canonical value for
60+
// the create/update path, which accepts only the user-creatable types. An empty
61+
// value returns an empty string so callers can fall back to the boolean
62+
// event-type flags.
2163
func normalizeEventType(raw string) (string, error) {
2264
raw = strings.TrimSpace(strings.ToLower(raw))
2365
if raw == "" {
2466
return "", nil
2567
}
26-
switch raw {
27-
case eventTypeDefault:
28-
return eventTypeDefault, nil
29-
case "focus", "focus-time", "focustime", "focus_time":
30-
return eventTypeFocusTime, nil
31-
case "out-of-office", "ooo", "outofoffice", "out_of_office":
32-
return eventTypeOutOfOffice, nil
33-
case "working-location", "workinglocation", "working_location", "wl":
34-
return eventTypeWorkingLocation, nil
35-
default:
36-
return "", usagef("invalid event type: %q (must be %s, focus-time, out-of-office, or working-location)", raw, eventTypeDefault)
68+
if canonical, ok := eventTypeAliases[raw]; ok && creatableEventTypes[canonical] {
69+
return canonical, nil
3770
}
71+
return "", usagef("invalid event type: %q (must be one of: default, focus-time, out-of-office, working-location)", raw)
3872
}
3973

4074
func resolveEventType(raw string, focusFlags, oooFlags, workingFlags bool) (string, error) {
@@ -83,3 +117,45 @@ func resolveEventType(raw string, focusFlags, oooFlags, workingFlags bool) (stri
83117
}
84118
return eventType, nil
85119
}
120+
121+
// normalizeFilterEventType maps a user-supplied event type (canonical or a
122+
// friendly alias) to a canonical Calendar API eventType value for the
123+
// events.list eventTypes filter. Unlike normalizeEventType, it accepts every
124+
// type the API can return — including the read-only birthday and fromGmail,
125+
// which are common things to filter on.
126+
func normalizeFilterEventType(raw string) (string, error) {
127+
if canonical, ok := eventTypeAliases[strings.TrimSpace(strings.ToLower(raw))]; ok {
128+
return canonical, nil
129+
}
130+
return "", usagef("invalid event type: %q (must be one of: default, birthday, focus-time, from-gmail, out-of-office, working-location)", raw)
131+
}
132+
133+
// resolveFilterEventTypes flattens repeated and comma-separated --event-types
134+
// values into a deduplicated list of canonical Calendar API eventType values,
135+
// preserving first-seen order. A nil/empty slice (flag absent) leaves the
136+
// request unfiltered — the API default of returning all types — while a flag
137+
// that resolves to no values (e.g. --event-types "") is a usage error.
138+
func resolveFilterEventTypes(raw []string) ([]string, error) {
139+
if len(raw) == 0 {
140+
return nil, nil
141+
}
142+
out := make([]string, 0, len(raw))
143+
seen := make(map[string]struct{})
144+
for _, item := range raw {
145+
for _, part := range splitCSV(item) {
146+
canonical, err := normalizeFilterEventType(part)
147+
if err != nil {
148+
return nil, err
149+
}
150+
if _, ok := seen[canonical]; ok {
151+
continue
152+
}
153+
seen[canonical] = struct{}{}
154+
out = append(out, canonical)
155+
}
156+
}
157+
if len(out) == 0 {
158+
return nil, usage("--event-types must include at least one value")
159+
}
160+
return out, nil
161+
}

internal/cmd/calendar_event_type_test.go

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package cmd
22

3-
import "testing"
3+
import (
4+
"slices"
5+
"testing"
6+
)
47

58
func TestNormalizeEventType(t *testing.T) {
69
cases := []struct {
@@ -35,3 +38,101 @@ func TestResolveEventTypeConflicts(t *testing.T) {
3538
_, err = resolveEventType("nope", false, false, false)
3639
requireUsageError(t, err)
3740
}
41+
42+
func TestNormalizeFilterEventType(t *testing.T) {
43+
cases := []struct {
44+
in string
45+
want string
46+
}{
47+
{"default", eventTypeDefault},
48+
{"birthday", eventTypeBirthday},
49+
{"BIRTHDAY", eventTypeBirthday},
50+
{"focus-time", eventTypeFocusTime},
51+
{"focusTime", eventTypeFocusTime},
52+
{"from-gmail", eventTypeFromGmail},
53+
{"fromGmail", eventTypeFromGmail},
54+
{"ooo", eventTypeOutOfOffice},
55+
{"out-of-office", eventTypeOutOfOffice},
56+
{"wl", eventTypeWorkingLocation},
57+
{" workingLocation ", eventTypeWorkingLocation},
58+
}
59+
for _, tc := range cases {
60+
got, err := normalizeFilterEventType(tc.in)
61+
if err != nil {
62+
t.Fatalf("normalizeFilterEventType(%q): %v", tc.in, err)
63+
}
64+
if got != tc.want {
65+
t.Fatalf("normalizeFilterEventType(%q) = %q, want %q", tc.in, got, tc.want)
66+
}
67+
}
68+
}
69+
70+
func TestNormalizeFilterEventTypeInvalid(t *testing.T) {
71+
// "gmail" is intentionally not an alias (too ambiguous); only "from-gmail"
72+
// and its spellings map to fromGmail.
73+
for _, in := range []string{"nope", "meeting", "gmail", ""} {
74+
if _, err := normalizeFilterEventType(in); err == nil {
75+
t.Fatalf("normalizeFilterEventType(%q): expected usage error", in)
76+
}
77+
}
78+
}
79+
80+
func TestCreatableEventTypesAreAliased(t *testing.T) {
81+
// Invariant: every creatable type must be reachable through the shared
82+
// eventTypeAliases map, so normalizeEventType can never reject a type that
83+
// creatableEventTypes claims is creatable. (The reverse need not hold:
84+
// the alias map is a superset that also includes the read-only types.)
85+
canonical := make(map[string]bool, len(eventTypeAliases))
86+
for _, c := range eventTypeAliases {
87+
canonical[c] = true
88+
}
89+
for ct := range creatableEventTypes {
90+
if !canonical[ct] {
91+
t.Fatalf("creatable event type %q has no entry in eventTypeAliases", ct)
92+
}
93+
}
94+
}
95+
96+
func TestNormalizeEventTypeRejectsFilterOnlyTypes(t *testing.T) {
97+
// birthday and fromGmail are valid filter values but are not user-creatable,
98+
// so the create/update normalizer must reject them.
99+
for _, in := range []string{"birthday", "fromGmail", "from-gmail"} {
100+
if _, err := normalizeEventType(in); err == nil {
101+
t.Fatalf("normalizeEventType(%q): expected usage error (not creatable)", in)
102+
}
103+
}
104+
}
105+
106+
func TestResolveFilterEventTypes(t *testing.T) {
107+
// Repeated and comma-separated values, with aliases, are flattened,
108+
// canonicalized, and deduplicated in first-seen order.
109+
got, err := resolveFilterEventTypes([]string{"birthday, workingLocation", "wl", "focus-time"})
110+
if err != nil {
111+
t.Fatalf("resolveFilterEventTypes: %v", err)
112+
}
113+
want := []string{eventTypeBirthday, eventTypeWorkingLocation, eventTypeFocusTime}
114+
if !slices.Equal(got, want) {
115+
t.Fatalf("resolveFilterEventTypes = %v, want %v", got, want)
116+
}
117+
118+
// Flag absent (nil or empty slice) leaves the request unfiltered.
119+
for _, in := range [][]string{nil, {}} {
120+
got, err := resolveFilterEventTypes(in)
121+
if err != nil {
122+
t.Fatalf("resolveFilterEventTypes(%v): %v", in, err)
123+
}
124+
if got != nil {
125+
t.Fatalf("resolveFilterEventTypes(%v) = %v, want nil", in, got)
126+
}
127+
}
128+
129+
// A flag that is present but resolves to no values is a usage error.
130+
if _, err := resolveFilterEventTypes([]string{"", " "}); err == nil {
131+
t.Fatal("resolveFilterEventTypes(blanks): expected usage error")
132+
}
133+
134+
// An invalid value anywhere is surfaced as an error.
135+
if _, err := resolveFilterEventTypes([]string{"default", "bogus"}); err == nil {
136+
t.Fatal("resolveFilterEventTypes: expected error for invalid type")
137+
}
138+
}

internal/cmd/calendar_events_cmds.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type CalendarEventsCmd struct {
2424
AllPages bool `name:"all-pages" aliases:"allpages" help:"Fetch all pages"`
2525
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"`
2626
Query string `name:"query" help:"Free text search"`
27+
EventTypes []string `name:"event-types" help:"Filter to event types (repeatable or comma-separated): default, birthday, focus-time, from-gmail, out-of-office, working-location"`
2728
All bool `name:"all" help:"Fetch events from all calendars"`
2829
PrivatePropFilter string `name:"private-prop-filter" help:"Filter by private extended property (key=value)"`
2930
SharedPropFilter string `name:"shared-prop-filter" help:"Filter by shared extended property (key=value)"`
@@ -88,8 +89,13 @@ func (c *CalendarEventsCmd) Run(ctx context.Context, flags *RootFlags) error {
8889

8990
from, to := timeRange.FormatRFC3339()
9091

92+
eventTypes, err := resolveFilterEventTypes(c.EventTypes)
93+
if err != nil {
94+
return err
95+
}
96+
9197
if c.All {
92-
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.Location, c.Sort, c.Order)
98+
return listAllCalendarsEvents(ctx, svc, from, to, c.Max, c.Page, c.AllPages, c.FailEmpty, c.Query, c.PrivatePropFilter, c.SharedPropFilter, c.Fields, eventTypes, c.Weekday, c.Location, c.Sort, c.Order)
9399
}
94100
if len(calInputs) > 0 {
95101
ids, err := resolveCalendarIDs(ctx, store, svc, calInputs)
@@ -99,9 +105,9 @@ func (c *CalendarEventsCmd) Run(ctx context.Context, flags *RootFlags) error {
99105
if len(ids) == 0 {
100106
return usage("no calendars specified")
101107
}
102-
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.Location, c.Sort, c.Order)
108+
return listSelectedCalendarsEvents(ctx, svc, ids, from, to, c.Max, c.Page, c.AllPages, c.FailEmpty, c.Query, c.PrivatePropFilter, c.SharedPropFilter, c.Fields, eventTypes, c.Weekday, c.Location, c.Sort, c.Order)
103109
}
104-
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.Location, c.Sort, c.Order)
110+
return listCalendarEvents(ctx, svc, calendarID, from, to, c.Max, c.Page, c.AllPages, c.FailEmpty, c.Query, c.PrivatePropFilter, c.SharedPropFilter, c.Fields, eventTypes, c.Weekday, c.Location, c.Sort, c.Order)
105111
}
106112

107113
func normalizeCalendarEventsArgs(args []string) (string, error) {

0 commit comments

Comments
 (0)