Skip to content
Merged
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 docs/commands/gog-calendar-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ gog calendar (cal) events (list,ls) [<calendarId> ...] [flags]
| `-n`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully |
| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) |
| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children |
| `--event-types` | `[]string` | | Filter to event types (repeatable or comma-separated): default, birthday, focus-time, from-gmail, out-of-office, working-location |
| `--fail-empty`<br>`--non-empty`<br>`--require-results` | `bool` | | Exit with code 3 if no results |
| `--fields` | `string` | | Comma-separated fields to return |
| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands |
Expand Down
3 changes: 2 additions & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,8 @@ Drive hierarchy semantics:
- `gog calendar create-calendar <summary> [--description D] [--timezone TZ] [--location L]`
- `gog calendar delete-calendar <ownedSecondaryCalendarId>`
- `gog calendar acl <calendarId>`
- `gog calendar events <calendarId> [--cal ID_OR_NAME] [--calendars CSV] [--all] [--from RFC3339] [--to RFC3339] [--max N] [--page TOKEN] [--query Q] [--weekday]`
- `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]`
- `--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).
- `gog calendar event|get <calendarId> <eventId>`
- `GOG_CALENDAR_WEEKDAY=1` defaults `--weekday` for `gog calendar events`
- `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 a@b.com,c@d.com] [--all-day] [--event-type TYPE]`
Expand Down
6 changes: 3 additions & 3 deletions internal/cmd/calendar_all_events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func TestListAllCalendarsEvents_JSON(t *testing.T) {
ctx := newCmdJSONContext(t)

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

ctx := newCmdJSONContext(t)
jsonOut := captureStdout(t, func() {
if err := listAllCalendarsEvents(ctx, svc, "2025-01-01T00:00:00Z", "2025-01-02T00:00:00Z", 10, "", false, false, "", "", "", "", false, false, "start", "asc"); err != nil {
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 {
t.Fatalf("listAllCalendarsEvents: %v", err)
}
})
Expand All @@ -153,7 +153,7 @@ func TestListAllCalendarsEvents_SortByStart(t *testing.T) {

// 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, false, "start", "desc"); err != nil {
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 {
t.Fatalf("listAllCalendarsEvents desc: %v", err)
}
})
Expand Down
98 changes: 87 additions & 11 deletions internal/cmd/calendar_event_type.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (

const (
eventTypeDefault = "default"
eventTypeBirthday = "birthday"
eventTypeFocusTime = "focusTime"
eventTypeFromGmail = "fromGmail"
eventTypeOutOfOffice = "outOfOffice"
eventTypeWorkingLocation = "workingLocation"

Expand All @@ -18,23 +20,55 @@ const (
defaultOOOAutoDecline = literalAll
)

// eventTypeAliases maps user-supplied event type spellings (canonical and
// friendly aliases, all lowercase) to the canonical Calendar API eventType
// value. It is the single source of truth for both the create/update path
// (which further restricts to creatableEventTypes) and the events.list
// eventTypes filter (which accepts every type the API can return).
var eventTypeAliases = map[string]string{
"default": eventTypeDefault,
"birthday": eventTypeBirthday,
"focus": eventTypeFocusTime,
"focus-time": eventTypeFocusTime,
"focustime": eventTypeFocusTime,
"focus_time": eventTypeFocusTime,
"from-gmail": eventTypeFromGmail,
"fromgmail": eventTypeFromGmail,
"from_gmail": eventTypeFromGmail,
"ooo": eventTypeOutOfOffice,
"out-of-office": eventTypeOutOfOffice,
"outofoffice": eventTypeOutOfOffice,
"out_of_office": eventTypeOutOfOffice,
"wl": eventTypeWorkingLocation,
"working-location": eventTypeWorkingLocation,
"workinglocation": eventTypeWorkingLocation,
"working_location": eventTypeWorkingLocation,
}

// creatableEventTypes are the event types that can be set when creating or
// updating an event. birthday and fromGmail are read-only — they sync from
// Google Contacts and Gmail — so the create/update path rejects them, even
// though they remain valid values for the events.list filter.
var creatableEventTypes = map[string]bool{
eventTypeDefault: true,
eventTypeFocusTime: true,
eventTypeOutOfOffice: true,
eventTypeWorkingLocation: true,
}

// normalizeEventType maps a user-supplied event type to a canonical value for
// the create/update path, which accepts only the user-creatable types. An empty
// value returns an empty string so callers can fall back to the boolean
// event-type flags.
func normalizeEventType(raw string) (string, error) {
raw = strings.TrimSpace(strings.ToLower(raw))
if raw == "" {
return "", nil
}
switch raw {
case eventTypeDefault:
return eventTypeDefault, nil
case "focus", "focus-time", "focustime", "focus_time":
return eventTypeFocusTime, nil
case "out-of-office", "ooo", "outofoffice", "out_of_office":
return eventTypeOutOfOffice, nil
case "working-location", "workinglocation", "working_location", "wl":
return eventTypeWorkingLocation, nil
default:
return "", usagef("invalid event type: %q (must be %s, focus-time, out-of-office, or working-location)", raw, eventTypeDefault)
if canonical, ok := eventTypeAliases[raw]; ok && creatableEventTypes[canonical] {
return canonical, nil
}
return "", usagef("invalid event type: %q (must be one of: default, focus-time, out-of-office, working-location)", raw)
}

func resolveEventType(raw string, focusFlags, oooFlags, workingFlags bool) (string, error) {
Expand Down Expand Up @@ -83,3 +117,45 @@ func resolveEventType(raw string, focusFlags, oooFlags, workingFlags bool) (stri
}
return eventType, nil
}

// normalizeFilterEventType maps a user-supplied event type (canonical or a
// friendly alias) to a canonical Calendar API eventType value for the
// events.list eventTypes filter. Unlike normalizeEventType, it accepts every
// type the API can return — including the read-only birthday and fromGmail,
// which are common things to filter on.
func normalizeFilterEventType(raw string) (string, error) {
if canonical, ok := eventTypeAliases[strings.TrimSpace(strings.ToLower(raw))]; ok {
return canonical, nil
}
return "", usagef("invalid event type: %q (must be one of: default, birthday, focus-time, from-gmail, out-of-office, working-location)", raw)
}

// resolveFilterEventTypes flattens repeated and comma-separated --event-types
// values into a deduplicated list of canonical Calendar API eventType values,
// preserving first-seen order. A nil/empty slice (flag absent) leaves the
// request unfiltered — the API default of returning all types — while a flag
// that resolves to no values (e.g. --event-types "") is a usage error.
func resolveFilterEventTypes(raw []string) ([]string, error) {
if len(raw) == 0 {
return nil, nil
}
out := make([]string, 0, len(raw))
seen := make(map[string]struct{})
for _, item := range raw {
for _, part := range splitCSV(item) {
canonical, err := normalizeFilterEventType(part)
if err != nil {
return nil, err
}
if _, ok := seen[canonical]; ok {
continue
}
seen[canonical] = struct{}{}
out = append(out, canonical)
}
}
if len(out) == 0 {
return nil, usage("--event-types must include at least one value")
}
return out, nil
}
103 changes: 102 additions & 1 deletion internal/cmd/calendar_event_type_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package cmd

import "testing"
import (
"slices"
"testing"
)

func TestNormalizeEventType(t *testing.T) {
cases := []struct {
Expand Down Expand Up @@ -35,3 +38,101 @@ func TestResolveEventTypeConflicts(t *testing.T) {
_, err = resolveEventType("nope", false, false, false)
requireUsageError(t, err)
}

func TestNormalizeFilterEventType(t *testing.T) {
cases := []struct {
in string
want string
}{
{"default", eventTypeDefault},
{"birthday", eventTypeBirthday},
{"BIRTHDAY", eventTypeBirthday},
{"focus-time", eventTypeFocusTime},
{"focusTime", eventTypeFocusTime},
{"from-gmail", eventTypeFromGmail},
{"fromGmail", eventTypeFromGmail},
{"ooo", eventTypeOutOfOffice},
{"out-of-office", eventTypeOutOfOffice},
{"wl", eventTypeWorkingLocation},
{" workingLocation ", eventTypeWorkingLocation},
}
for _, tc := range cases {
got, err := normalizeFilterEventType(tc.in)
if err != nil {
t.Fatalf("normalizeFilterEventType(%q): %v", tc.in, err)
}
if got != tc.want {
t.Fatalf("normalizeFilterEventType(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}

func TestNormalizeFilterEventTypeInvalid(t *testing.T) {
// "gmail" is intentionally not an alias (too ambiguous); only "from-gmail"
// and its spellings map to fromGmail.
for _, in := range []string{"nope", "meeting", "gmail", ""} {
if _, err := normalizeFilterEventType(in); err == nil {
t.Fatalf("normalizeFilterEventType(%q): expected usage error", in)
}
}
}

func TestCreatableEventTypesAreAliased(t *testing.T) {
// Invariant: every creatable type must be reachable through the shared
// eventTypeAliases map, so normalizeEventType can never reject a type that
// creatableEventTypes claims is creatable. (The reverse need not hold:
// the alias map is a superset that also includes the read-only types.)
canonical := make(map[string]bool, len(eventTypeAliases))
for _, c := range eventTypeAliases {
canonical[c] = true
}
for ct := range creatableEventTypes {
if !canonical[ct] {
t.Fatalf("creatable event type %q has no entry in eventTypeAliases", ct)
}
}
}

func TestNormalizeEventTypeRejectsFilterOnlyTypes(t *testing.T) {
// birthday and fromGmail are valid filter values but are not user-creatable,
// so the create/update normalizer must reject them.
for _, in := range []string{"birthday", "fromGmail", "from-gmail"} {
if _, err := normalizeEventType(in); err == nil {
t.Fatalf("normalizeEventType(%q): expected usage error (not creatable)", in)
}
}
}

func TestResolveFilterEventTypes(t *testing.T) {
// Repeated and comma-separated values, with aliases, are flattened,
// canonicalized, and deduplicated in first-seen order.
got, err := resolveFilterEventTypes([]string{"birthday, workingLocation", "wl", "focus-time"})
if err != nil {
t.Fatalf("resolveFilterEventTypes: %v", err)
}
want := []string{eventTypeBirthday, eventTypeWorkingLocation, eventTypeFocusTime}
if !slices.Equal(got, want) {
t.Fatalf("resolveFilterEventTypes = %v, want %v", got, want)
}

// Flag absent (nil or empty slice) leaves the request unfiltered.
for _, in := range [][]string{nil, {}} {
got, err := resolveFilterEventTypes(in)
if err != nil {
t.Fatalf("resolveFilterEventTypes(%v): %v", in, err)
}
if got != nil {
t.Fatalf("resolveFilterEventTypes(%v) = %v, want nil", in, got)
}
}

// A flag that is present but resolves to no values is a usage error.
if _, err := resolveFilterEventTypes([]string{"", " "}); err == nil {
t.Fatal("resolveFilterEventTypes(blanks): expected usage error")
}

// An invalid value anywhere is surfaced as an error.
if _, err := resolveFilterEventTypes([]string{"default", "bogus"}); err == nil {
t.Fatal("resolveFilterEventTypes: expected error for invalid type")
}
}
12 changes: 9 additions & 3 deletions internal/cmd/calendar_events_cmds.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type CalendarEventsCmd struct {
AllPages bool `name:"all-pages" aliases:"allpages" help:"Fetch all pages"`
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"`
Query string `name:"query" help:"Free text search"`
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"`
All bool `name:"all" help:"Fetch events from all calendars"`
PrivatePropFilter string `name:"private-prop-filter" help:"Filter by private extended property (key=value)"`
SharedPropFilter string `name:"shared-prop-filter" help:"Filter by shared extended property (key=value)"`
Expand Down Expand Up @@ -88,8 +89,13 @@ func (c *CalendarEventsCmd) Run(ctx context.Context, flags *RootFlags) error {

from, to := timeRange.FormatRFC3339()

eventTypes, err := resolveFilterEventTypes(c.EventTypes)
if err != nil {
return err
}

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, c.Location, c.Sort, c.Order)
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)
}
if len(calInputs) > 0 {
ids, err := resolveCalendarIDs(ctx, store, svc, calInputs)
Expand All @@ -99,9 +105,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, c.Location, c.Sort, c.Order)
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)
}
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)
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)
}

func normalizeCalendarEventsArgs(args []string) (string, error) {
Expand Down
Loading
Loading