From d808b47ee048149eb2445ea1b0a06e8f92e5b64b Mon Sep 17 00:00:00 2001 From: Zachary Date: Fri, 23 Jan 2026 10:15:10 -0500 Subject: [PATCH 1/7] feat(config): add CalendarAliases field to config Add support for storing calendar aliases in config.json, following the same pattern as account aliases. Includes CRUD functions and tests. --- internal/config/calendar_aliases.go | 104 ++++++++++++++++++++ internal/config/calendar_aliases_test.go | 120 +++++++++++++++++++++++ internal/config/config.go | 1 + 3 files changed, 225 insertions(+) create mode 100644 internal/config/calendar_aliases.go create mode 100644 internal/config/calendar_aliases_test.go diff --git a/internal/config/calendar_aliases.go b/internal/config/calendar_aliases.go new file mode 100644 index 00000000..11d7ae2a --- /dev/null +++ b/internal/config/calendar_aliases.go @@ -0,0 +1,104 @@ +package config + +import "strings" + +func NormalizeCalendarAlias(alias string) string { + return strings.ToLower(strings.TrimSpace(alias)) +} + +func ResolveCalendarAlias(alias string) (string, bool, error) { + alias = NormalizeCalendarAlias(alias) + if alias == "" { + return "", false, nil + } + + cfg, err := ReadConfig() + if err != nil { + return "", false, err + } + + if cfg.CalendarAliases == nil { + return "", false, nil + } + + calendarID, ok := cfg.CalendarAliases[alias] + + return calendarID, ok, nil +} + +// ResolveCalendarID resolves a calendar ID, checking aliases first. +// If the input matches an alias, returns the mapped calendar ID. +// Otherwise returns the input unchanged. +func ResolveCalendarID(calendarID string) (string, error) { + calendarID = strings.TrimSpace(calendarID) + if calendarID == "" { + return "primary", nil + } + + resolved, ok, err := ResolveCalendarAlias(calendarID) + if err != nil { + return "", err + } + if ok { + return resolved, nil + } + + return calendarID, nil +} + +func SetCalendarAlias(alias, calendarID string) error { + alias = NormalizeCalendarAlias(alias) + calendarID = strings.TrimSpace(calendarID) + + cfg, err := ReadConfig() + if err != nil { + return err + } + + if cfg.CalendarAliases == nil { + cfg.CalendarAliases = map[string]string{} + } + + cfg.CalendarAliases[alias] = calendarID + + return WriteConfig(cfg) +} + +func DeleteCalendarAlias(alias string) (bool, error) { + alias = NormalizeCalendarAlias(alias) + + cfg, err := ReadConfig() + if err != nil { + return false, err + } + + if cfg.CalendarAliases == nil { + return false, nil + } + + if _, ok := cfg.CalendarAliases[alias]; !ok { + return false, nil + } + + delete(cfg.CalendarAliases, alias) + + return true, WriteConfig(cfg) +} + +func ListCalendarAliases() (map[string]string, error) { + cfg, err := ReadConfig() + if err != nil { + return nil, err + } + + if cfg.CalendarAliases == nil { + return map[string]string{}, nil + } + + out := make(map[string]string, len(cfg.CalendarAliases)) + for k, v := range cfg.CalendarAliases { + out[k] = v + } + + return out, nil +} diff --git a/internal/config/calendar_aliases_test.go b/internal/config/calendar_aliases_test.go new file mode 100644 index 00000000..f57aac2d --- /dev/null +++ b/internal/config/calendar_aliases_test.go @@ -0,0 +1,120 @@ +package config + +import ( + "path/filepath" + "testing" +) + +func TestCalendarAliasesCRUD(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + if err := SetCalendarAlias("family", "3656f8abc123@group.calendar.google.com"); err != nil { + t.Fatalf("set alias: %v", err) + } + + calID, ok, err := ResolveCalendarAlias("family") + if err != nil { + t.Fatalf("resolve alias: %v", err) + } + + if !ok || calID != "3656f8abc123@group.calendar.google.com" { + t.Fatalf("unexpected alias resolve: ok=%v calID=%q", ok, calID) + } + + aliases, err := ListCalendarAliases() + if err != nil { + t.Fatalf("list aliases: %v", err) + } + + if aliases["family"] != "3656f8abc123@group.calendar.google.com" { + t.Fatalf("unexpected alias list: %#v", aliases) + } + + deleted, err := DeleteCalendarAlias("family") + if err != nil { + t.Fatalf("delete alias: %v", err) + } + + if !deleted { + t.Fatalf("expected alias delete") + } +} + +func TestResolveCalendarID(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + // Empty returns primary + resolved, err := ResolveCalendarID("") + if err != nil { + t.Fatalf("resolve empty: %v", err) + } + if resolved != "primary" { + t.Fatalf("expected primary for empty, got %q", resolved) + } + + // Non-alias returns unchanged + resolved, err = ResolveCalendarID("some-calendar-id@group.calendar.google.com") + if err != nil { + t.Fatalf("resolve non-alias: %v", err) + } + if resolved != "some-calendar-id@group.calendar.google.com" { + t.Fatalf("expected unchanged, got %q", resolved) + } + + // Set alias and resolve + if err := SetCalendarAlias("work", "work-calendar@group.calendar.google.com"); err != nil { + t.Fatalf("set alias: %v", err) + } + + resolved, err = ResolveCalendarID("work") + if err != nil { + t.Fatalf("resolve alias: %v", err) + } + if resolved != "work-calendar@group.calendar.google.com" { + t.Fatalf("expected resolved alias, got %q", resolved) + } + + // Alias lookup is case-insensitive + resolved, err = ResolveCalendarID("WORK") + if err != nil { + t.Fatalf("resolve uppercase alias: %v", err) + } + if resolved != "work-calendar@group.calendar.google.com" { + t.Fatalf("expected resolved alias for uppercase, got %q", resolved) + } +} + +func TestCalendarAliasNormalization(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + // Set with mixed case and whitespace + if err := SetCalendarAlias(" Family ", "family-cal@group.calendar.google.com"); err != nil { + t.Fatalf("set alias: %v", err) + } + + // Resolve with different case + calID, ok, err := ResolveCalendarAlias("FAMILY") + if err != nil { + t.Fatalf("resolve alias: %v", err) + } + + if !ok || calID != "family-cal@group.calendar.google.com" { + t.Fatalf("unexpected alias resolve: ok=%v calID=%q", ok, calID) + } + + // Delete with different case + deleted, err := DeleteCalendarAlias("family") + if err != nil { + t.Fatalf("delete alias: %v", err) + } + + if !deleted { + t.Fatalf("expected alias delete") + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 50ab6abe..de31a043 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,7 @@ type File struct { AccountAliases map[string]string `json:"account_aliases,omitempty"` AccountClients map[string]string `json:"account_clients,omitempty"` ClientDomains map[string]string `json:"client_domains,omitempty"` + CalendarAliases map[string]string `json:"calendar_aliases,omitempty"` } func ConfigPath() (string, error) { From 8117cf288a0974ea7255132777d2cce2a92e14db Mon Sep 17 00:00:00 2001 From: Zachary Date: Fri, 23 Jan 2026 10:16:15 -0500 Subject: [PATCH 2/7] feat(calendar): add calendar alias CLI commands Add gog calendar alias {list,set,unset} commands for managing calendar aliases. These allow mapping friendly names like 'family' to full calendar IDs like '3656f8abc123@group.calendar.google.com'. --- internal/cmd/calendar_alias.go | 108 +++++++++++++++++++ internal/cmd/calendar_alias_test.go | 158 ++++++++++++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 internal/cmd/calendar_alias.go create mode 100644 internal/cmd/calendar_alias_test.go diff --git a/internal/cmd/calendar_alias.go b/internal/cmd/calendar_alias.go new file mode 100644 index 00000000..cdf79763 --- /dev/null +++ b/internal/cmd/calendar_alias.go @@ -0,0 +1,108 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + + "github.com/steipete/gogcli/internal/config" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type CalendarAliasCmd struct { + List CalendarAliasListCmd `cmd:"" name:"list" help:"List calendar aliases"` + Set CalendarAliasSetCmd `cmd:"" name:"set" help:"Set a calendar alias"` + Unset CalendarAliasUnsetCmd `cmd:"" name:"unset" help:"Remove a calendar alias"` +} + +type CalendarAliasListCmd struct{} + +func (c *CalendarAliasListCmd) Run(ctx context.Context) error { + u := ui.FromContext(ctx) + aliases, err := config.ListCalendarAliases() + if err != nil { + return err + } + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{"aliases": aliases}) + } + if len(aliases) == 0 { + u.Err().Println("No calendar aliases") + return nil + } + keys := make([]string, 0, len(aliases)) + for k := range aliases { + keys = append(keys, k) + } + sort.Strings(keys) + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "ALIAS\tCALENDAR_ID") + for _, k := range keys { + fmt.Fprintf(w, "%s\t%s\n", k, aliases[k]) + } + return nil +} + +type CalendarAliasSetCmd struct { + Alias string `arg:"" name:"alias" help:"Alias name (no spaces)"` + CalendarID string `arg:"" name:"calendarId" help:"Calendar ID (e.g., abc123@group.calendar.google.com)"` +} + +func (c *CalendarAliasSetCmd) Run(ctx context.Context) error { + u := ui.FromContext(ctx) + alias := strings.TrimSpace(c.Alias) + if alias == "" { + return usage("empty alias") + } + if strings.ContainsAny(alias, " \t\n") { + return usage("alias must not contain whitespace") + } + calendarID := strings.TrimSpace(c.CalendarID) + if calendarID == "" { + return usage("empty calendar ID") + } + if err := config.SetCalendarAlias(alias, calendarID); err != nil { + return err + } + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "alias": strings.ToLower(alias), + "calendarId": calendarID, + }) + } + u.Out().Printf("alias\t%s", strings.ToLower(alias)) + u.Out().Printf("calendarId\t%s", calendarID) + return nil +} + +type CalendarAliasUnsetCmd struct { + Alias string `arg:"" name:"alias" help:"Alias name"` +} + +func (c *CalendarAliasUnsetCmd) Run(ctx context.Context) error { + u := ui.FromContext(ctx) + alias := strings.TrimSpace(c.Alias) + if alias == "" { + return usage("empty alias") + } + deleted, err := config.DeleteCalendarAlias(alias) + if err != nil { + return err + } + if !deleted { + return usage("alias not found") + } + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "deleted": true, + "alias": strings.ToLower(alias), + }) + } + u.Out().Printf("deleted\ttrue") + u.Out().Printf("alias\t%s", strings.ToLower(alias)) + return nil +} diff --git a/internal/cmd/calendar_alias_test.go b/internal/cmd/calendar_alias_test.go new file mode 100644 index 00000000..fd22b083 --- /dev/null +++ b/internal/cmd/calendar_alias_test.go @@ -0,0 +1,158 @@ +package cmd + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/steipete/gogcli/internal/config" + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +func TestCalendarAliasSetListUnset_JSON(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) + + // set + _ = captureStdout(t, func() { + if err := runKong(t, &CalendarAliasSetCmd{}, []string{"family", "3656f8abc123@group.calendar.google.com"}, ctx, &RootFlags{}); err != nil { + t.Fatalf("set: %v", err) + } + }) + + // list + out := captureStdout(t, func() { + if err := runKong(t, &CalendarAliasListCmd{}, []string{}, ctx, &RootFlags{}); err != nil { + t.Fatalf("list: %v", err) + } + }) + var listResp struct { + Aliases map[string]string `json:"aliases"` + } + if err := json.Unmarshal([]byte(out), &listResp); err != nil { + t.Fatalf("list json: %v", err) + } + if listResp.Aliases["family"] != "3656f8abc123@group.calendar.google.com" { + t.Fatalf("unexpected aliases: %#v", listResp.Aliases) + } + + // unset + _ = captureStdout(t, func() { + if err := runKong(t, &CalendarAliasUnsetCmd{}, []string{"family"}, ctx, &RootFlags{}); err != nil { + t.Fatalf("unset: %v", err) + } + }) + + // Verify the alias was deleted + _, ok, err := config.ResolveCalendarAlias("family") + if err != nil { + t.Fatalf("failed to resolve alias: %v", err) + } + if ok { + t.Error("alias should have been deleted") + } +} + +func TestCalendarAliasSetCmd_Validation(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + tests := []struct { + name string + args []string + wantErr string + }{ + {"whitespace in alias", []string{"my family", "cal@group.calendar.google.com"}, "whitespace"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := runKong(t, &CalendarAliasSetCmd{}, tt.args, ctx, &RootFlags{}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("expected error containing %q, got: %v", tt.wantErr, err) + } + }) + } +} + +func TestCalendarAliasUnsetCmd_NotFound(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + err = runKong(t, &CalendarAliasUnsetCmd{}, []string{"nonexistent"}, ctx, &RootFlags{}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "alias not found") { + t.Errorf("expected 'alias not found' error, got: %v", err) + } +} + +func TestResolveCalendarID_Integration(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg-config")) + + // Set up alias + if err := config.SetCalendarAlias("family", "family-cal@group.calendar.google.com"); err != nil { + t.Fatalf("failed to set alias: %v", err) + } + + tests := []struct { + name string + input string + expected string + wantErr bool + }{ + {"alias resolved", "family", "family-cal@group.calendar.google.com", false}, + {"non-alias passthrough", "some-calendar@group.calendar.google.com", "some-calendar@group.calendar.google.com", false}, + {"primary passthrough", "primary", "primary", false}, + {"empty returns error", "", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveCalendarID(tt.input) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, got) + } + }) + } +} From 39cc57ace758b49813d9cfde966ac5c358e70867 Mon Sep 17 00:00:00 2001 From: Zachary Date: Fri, 23 Jan 2026 10:28:19 -0500 Subject: [PATCH 3/7] feat(calendar): add alias resolution helper Add resolveCalendarID helper function and register CalendarAliasCmd in the calendar command tree. --- internal/cmd/calendar.go | 28 +++++++++++++++++++--------- internal/cmd/calendar_util.go | 24 +++++++++++++++++++++++- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/internal/cmd/calendar.go b/internal/cmd/calendar.go index d1c8ba3d..6baa8351 100644 --- a/internal/cmd/calendar.go +++ b/internal/cmd/calendar.go @@ -13,6 +13,7 @@ import ( type CalendarCmd struct { Calendars CalendarCalendarsCmd `cmd:"" name:"calendars" help:"List calendars"` ACL CalendarAclCmd `cmd:"" name:"acl" help:"List calendar ACL"` + Alias CalendarAliasCmd `cmd:"" name:"alias" help:"Manage calendar aliases"` Events CalendarEventsCmd `cmd:"" name:"events" aliases:"list" help:"List events from a calendar or all calendars"` Event CalendarEventCmd `cmd:"" name:"event" aliases:"get" help:"Get event"` Create CalendarCreateCmd `cmd:"" name:"create" help:"Create an event"` @@ -86,9 +87,9 @@ func (c *CalendarAclCmd) Run(ctx context.Context, flags *RootFlags) error { if err != nil { return err } - calendarID := strings.TrimSpace(c.CalendarID) - if calendarID == "" { - return usage("calendarId required") + calendarID, err := resolveCalendarID(c.CalendarID) + if err != nil { + return err } svc, err := newCalendarService(ctx, account) @@ -156,8 +157,17 @@ func (c *CalendarEventsCmd) Run(ctx context.Context, flags *RootFlags) error { if c.All && calendarID != "" { return usage("calendarId not allowed with --all flag") } - if !c.All && calendarID == "" { - calendarID = "primary" + if !c.All { + if calendarID == "" { + calendarID = "primary" + } else { + // Resolve alias if provided + resolved, resolveErr := resolveCalendarID(calendarID) + if resolveErr != nil { + return resolveErr + } + calendarID = resolved + } } svc, err := newCalendarService(ctx, account) @@ -198,11 +208,11 @@ func (c *CalendarEventCmd) Run(ctx context.Context, flags *RootFlags) error { if err != nil { return err } - calendarID := strings.TrimSpace(c.CalendarID) - eventID := strings.TrimSpace(c.EventID) - if calendarID == "" { - return usage("empty calendarId") + calendarID, err := resolveCalendarID(c.CalendarID) + if err != nil { + return err } + eventID := strings.TrimSpace(c.EventID) if eventID == "" { return usage("empty eventId") } diff --git a/internal/cmd/calendar_util.go b/internal/cmd/calendar_util.go index 4be2931a..3e322018 100644 --- a/internal/cmd/calendar_util.go +++ b/internal/cmd/calendar_util.go @@ -1,7 +1,29 @@ package cmd -import "google.golang.org/api/calendar/v3" +import ( + "strings" + + "google.golang.org/api/calendar/v3" + + "github.com/steipete/gogcli/internal/config" +) func isAllDayEvent(e *calendar.Event) bool { return e != nil && e.Start != nil && e.Start.Date != "" } + +// resolveCalendarID resolves a calendar ID, checking aliases first. +// Returns an error if the calendar ID is empty after resolution. +func resolveCalendarID(calendarID string) (string, error) { + calendarID = strings.TrimSpace(calendarID) + if calendarID == "" { + return "", usage("empty calendarId") + } + + resolved, err := config.ResolveCalendarID(calendarID) + if err != nil { + return "", err + } + + return resolved, nil +} From a11a51b6faeb4b139fb4ed01ca59854c9ef65aca Mon Sep 17 00:00:00 2001 From: Zachary Date: Fri, 23 Jan 2026 10:28:29 -0500 Subject: [PATCH 4/7] feat(calendar): resolve aliases in create/update/delete commands Update CalendarCreateCmd, CalendarUpdateCmd, CalendarDeleteCmd, CalendarProposeTimeCmd, and CalendarRespondCmd to resolve calendar aliases before using the calendar ID. --- internal/cmd/calendar_edit.go | 22 +++++++++++----------- internal/cmd/calendar_propose_time.go | 8 ++++---- internal/cmd/calendar_respond.go | 8 ++++---- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/internal/cmd/calendar_edit.go b/internal/cmd/calendar_edit.go index 0c364c8d..fdc3cbd8 100644 --- a/internal/cmd/calendar_edit.go +++ b/internal/cmd/calendar_edit.go @@ -57,9 +57,9 @@ func (c *CalendarCreateCmd) Run(ctx context.Context, flags *RootFlags) error { if err != nil { return err } - calendarID := strings.TrimSpace(c.CalendarID) - if calendarID == "" { - return usage("empty calendarId") + calendarID, err := resolveCalendarID(c.CalendarID) + if err != nil { + return err } eventType, err := c.resolveCreateEventType() @@ -337,11 +337,11 @@ func (c *CalendarUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags * if err != nil { return err } - calendarID := strings.TrimSpace(c.CalendarID) - eventID := strings.TrimSpace(c.EventID) - if calendarID == "" { - return usage("empty calendarId") + calendarID, err := resolveCalendarID(c.CalendarID) + if err != nil { + return err } + eventID := strings.TrimSpace(c.EventID) if eventID == "" { return usage("empty eventId") } @@ -813,11 +813,11 @@ func (c *CalendarDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { if err != nil { return err } - calendarID := strings.TrimSpace(c.CalendarID) - eventID := strings.TrimSpace(c.EventID) - if calendarID == "" { - return usage("empty calendarId") + calendarID, err := resolveCalendarID(c.CalendarID) + if err != nil { + return err } + eventID := strings.TrimSpace(c.EventID) if eventID == "" { return usage("empty eventId") } diff --git a/internal/cmd/calendar_propose_time.go b/internal/cmd/calendar_propose_time.go index c1938db8..a2a560a2 100644 --- a/internal/cmd/calendar_propose_time.go +++ b/internal/cmd/calendar_propose_time.go @@ -38,11 +38,11 @@ func (c *CalendarProposeTimeCmd) Run(ctx context.Context, flags *RootFlags) erro return err } - calendarID := strings.TrimSpace(c.CalendarID) - eventID := strings.TrimSpace(c.EventID) - if calendarID == "" { - return usage("empty calendarId") + calendarID, err := resolveCalendarID(c.CalendarID) + if err != nil { + return err } + eventID := strings.TrimSpace(c.EventID) if eventID == "" { return usage("empty eventId") } diff --git a/internal/cmd/calendar_respond.go b/internal/cmd/calendar_respond.go index 6c8e2841..b5f57d20 100644 --- a/internal/cmd/calendar_respond.go +++ b/internal/cmd/calendar_respond.go @@ -24,11 +24,11 @@ func (c *CalendarRespondCmd) Run(ctx context.Context, flags *RootFlags) error { if err != nil { return err } - calendarID := strings.TrimSpace(c.CalendarID) - eventID := strings.TrimSpace(c.EventID) - if calendarID == "" { - return usage("empty calendarId") + calendarID, err := resolveCalendarID(c.CalendarID) + if err != nil { + return err } + eventID := strings.TrimSpace(c.EventID) if eventID == "" { return usage("empty eventId") } From 53572cd37f073c18860c292707a6c316a7e356b7 Mon Sep 17 00:00:00 2001 From: Zachary Date: Fri, 23 Jan 2026 10:28:39 -0500 Subject: [PATCH 5/7] feat(calendar): resolve aliases in specialized event commands Update focus-time, out-of-office, working-location, and time commands to resolve calendar aliases. --- internal/cmd/calendar_focus_time.go | 15 +++++++++++++-- internal/cmd/calendar_ooo.go | 15 +++++++++++++-- internal/cmd/calendar_time.go | 13 ++++++++++++- internal/cmd/calendar_working_location.go | 15 +++++++++++++-- 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/internal/cmd/calendar_focus_time.go b/internal/cmd/calendar_focus_time.go index 24a107ea..95757b18 100644 --- a/internal/cmd/calendar_focus_time.go +++ b/internal/cmd/calendar_focus_time.go @@ -30,6 +30,17 @@ func (c *CalendarFocusTimeCmd) Run(ctx context.Context, flags *RootFlags) error return err } + calendarID := c.CalendarID + if calendarID == "" { + calendarID = "primary" + } else { + resolved, resolveErr := resolveCalendarID(calendarID) + if resolveErr != nil { + return resolveErr + } + calendarID = resolved + } + autoDeclineMode, err := validateAutoDeclineMode(c.AutoDecline) if err != nil { return err @@ -59,12 +70,12 @@ func (c *CalendarFocusTimeCmd) Run(ctx context.Context, flags *RootFlags) error Recurrence: buildRecurrence(c.Recurrence), } - created, err := svc.Events.Insert(c.CalendarID, event).Do() + created, err := svc.Events.Insert(calendarID, event).Do() if err != nil { return err } - tz, loc, _ := getCalendarLocation(ctx, svc, c.CalendarID) + tz, loc, _ := getCalendarLocation(ctx, svc, calendarID) if outfmt.IsJSON(ctx) { return outfmt.WriteJSON(os.Stdout, map[string]any{"event": wrapEventWithDaysWithTimezone(created, tz, loc)}) } diff --git a/internal/cmd/calendar_ooo.go b/internal/cmd/calendar_ooo.go index 8f010959..8c916f1b 100644 --- a/internal/cmd/calendar_ooo.go +++ b/internal/cmd/calendar_ooo.go @@ -28,6 +28,17 @@ func (c *CalendarOOOCmd) Run(ctx context.Context, flags *RootFlags) error { return err } + calendarID := c.CalendarID + if calendarID == "" { + calendarID = "primary" + } else { + resolved, resolveErr := resolveCalendarID(calendarID) + if resolveErr != nil { + return resolveErr + } + calendarID = resolved + } + autoDeclineMode, err := validateAutoDeclineMode(c.AutoDecline) if err != nil { return err @@ -50,12 +61,12 @@ func (c *CalendarOOOCmd) Run(ctx context.Context, flags *RootFlags) error { }, } - created, err := svc.Events.Insert(c.CalendarID, event).Do() + created, err := svc.Events.Insert(calendarID, event).Do() if err != nil { return err } - tz, loc, _ := getCalendarLocation(ctx, svc, c.CalendarID) + tz, loc, _ := getCalendarLocation(ctx, svc, calendarID) if outfmt.IsJSON(ctx) { return outfmt.WriteJSON(os.Stdout, map[string]any{"event": wrapEventWithDaysWithTimezone(created, tz, loc)}) } diff --git a/internal/cmd/calendar_time.go b/internal/cmd/calendar_time.go index 02e8d4e9..f41c8736 100644 --- a/internal/cmd/calendar_time.go +++ b/internal/cmd/calendar_time.go @@ -21,6 +21,17 @@ func (c *CalendarTimeCmd) Run(ctx context.Context, flags *RootFlags) error { return err } + calendarID := c.CalendarID + if calendarID == "" { + calendarID = "primary" + } else { + resolved, resolveErr := resolveCalendarID(calendarID) + if resolveErr != nil { + return resolveErr + } + calendarID = resolved + } + var tz string var loc *time.Location @@ -40,7 +51,7 @@ func (c *CalendarTimeCmd) Run(ctx context.Context, flags *RootFlags) error { return err } - tz, loc, err = getCalendarLocation(ctx, svc, c.CalendarID) + tz, loc, err = getCalendarLocation(ctx, svc, calendarID) if err != nil { return err } diff --git a/internal/cmd/calendar_working_location.go b/internal/cmd/calendar_working_location.go index 2f295c3d..0a1755c1 100644 --- a/internal/cmd/calendar_working_location.go +++ b/internal/cmd/calendar_working_location.go @@ -31,6 +31,17 @@ func (c *CalendarWorkingLocationCmd) Run(ctx context.Context, flags *RootFlags) return err } + calendarID := c.CalendarID + if calendarID == "" { + calendarID = "primary" + } else { + resolved, resolveErr := resolveCalendarID(calendarID) + if resolveErr != nil { + return resolveErr + } + calendarID = resolved + } + props, err := c.buildWorkingLocationProperties() if err != nil { return err @@ -51,12 +62,12 @@ func (c *CalendarWorkingLocationCmd) Run(ctx context.Context, flags *RootFlags) WorkingLocationProperties: props, } - created, err := svc.Events.Insert(c.CalendarID, event).Do() + created, err := svc.Events.Insert(calendarID, event).Do() if err != nil { return err } - tz, loc, _ := getCalendarLocation(ctx, svc, c.CalendarID) + tz, loc, _ := getCalendarLocation(ctx, svc, calendarID) if outfmt.IsJSON(ctx) { return outfmt.WriteJSON(os.Stdout, map[string]any{"event": wrapEventWithDaysWithTimezone(created, tz, loc)}) } From 33995a64f53f3c612a5aba9b6010b5f914661493 Mon Sep 17 00:00:00 2001 From: Zachary Date: Fri, 23 Jan 2026 10:28:50 -0500 Subject: [PATCH 6/7] feat(calendar): resolve aliases in search and multi-calendar commands Update search, freebusy, and conflicts commands to resolve calendar aliases. Multi-calendar commands now resolve each calendar ID in the comma-separated list. --- internal/cmd/calendar_conflicts.go | 14 ++++++++++++-- internal/cmd/calendar_freebusy.go | 15 +++++++++++++-- internal/cmd/calendar_search.go | 13 ++++++++++++- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/internal/cmd/calendar_conflicts.go b/internal/cmd/calendar_conflicts.go index 3dbc85fa..d832cb05 100644 --- a/internal/cmd/calendar_conflicts.go +++ b/internal/cmd/calendar_conflicts.go @@ -43,6 +43,16 @@ func (c *CalendarConflictsCmd) Run(ctx context.Context, flags *RootFlags) error return errors.New("no calendar IDs provided") } + // Resolve aliases for all calendar IDs + resolvedIDs := make([]string, 0, len(calendarIDs)) + for _, id := range calendarIDs { + resolved, resolveErr := resolveCalendarID(id) + if resolveErr != nil { + return resolveErr + } + resolvedIDs = append(resolvedIDs, resolved) + } + svc, err := newCalendarService(ctx, account) if err != nil { return err @@ -63,8 +73,8 @@ func (c *CalendarConflictsCmd) Run(ctx context.Context, flags *RootFlags) error from, to := timeRange.FormatRFC3339() - items := make([]*calendar.FreeBusyRequestItem, 0, len(calendarIDs)) - for _, id := range calendarIDs { + items := make([]*calendar.FreeBusyRequestItem, 0, len(resolvedIDs)) + for _, id := range resolvedIDs { items = append(items, &calendar.FreeBusyRequestItem{Id: id}) } diff --git a/internal/cmd/calendar_freebusy.go b/internal/cmd/calendar_freebusy.go index d34c3ead..1257a686 100644 --- a/internal/cmd/calendar_freebusy.go +++ b/internal/cmd/calendar_freebusy.go @@ -29,6 +29,17 @@ func (c *CalendarFreeBusyCmd) Run(ctx context.Context, flags *RootFlags) error { if len(calendarIDs) == 0 { return usage("no calendar IDs provided") } + + // Resolve aliases for all calendar IDs + resolvedIDs := make([]string, 0, len(calendarIDs)) + for _, id := range calendarIDs { + resolved, resolveErr := resolveCalendarID(id) + if resolveErr != nil { + return resolveErr + } + resolvedIDs = append(resolvedIDs, resolved) + } + if strings.TrimSpace(c.From) == "" || strings.TrimSpace(c.To) == "" { return usage("required: --from and --to") } @@ -41,9 +52,9 @@ func (c *CalendarFreeBusyCmd) Run(ctx context.Context, flags *RootFlags) error { req := &calendar.FreeBusyRequest{ TimeMin: c.From, TimeMax: c.To, - Items: make([]*calendar.FreeBusyRequestItem, 0, len(calendarIDs)), + Items: make([]*calendar.FreeBusyRequestItem, 0, len(resolvedIDs)), } - for _, id := range calendarIDs { + for _, id := range resolvedIDs { req.Items = append(req.Items, &calendar.FreeBusyRequestItem{Id: id}) } diff --git a/internal/cmd/calendar_search.go b/internal/cmd/calendar_search.go index 38a74f34..55d3a263 100644 --- a/internal/cmd/calendar_search.go +++ b/internal/cmd/calendar_search.go @@ -30,6 +30,17 @@ func (c *CalendarSearchCmd) Run(ctx context.Context, flags *RootFlags) error { return fmt.Errorf("search query cannot be empty") } + calendarID := c.CalendarID + if calendarID == "" { + calendarID = "primary" + } else { + resolved, resolveErr := resolveCalendarID(calendarID) + if resolveErr != nil { + return resolveErr + } + calendarID = resolved + } + svc, err := newCalendarService(ctx, account) if err != nil { return err @@ -44,7 +55,7 @@ func (c *CalendarSearchCmd) Run(ctx context.Context, flags *RootFlags) error { } from, to := timeRange.FormatRFC3339() - call := svc.Events.List(c.CalendarID). + call := svc.Events.List(calendarID). Q(query). TimeMin(from). TimeMax(to). From 3e2b78fce0785df78df1abf771e49a0000830e88 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:08:26 -0800 Subject: [PATCH 7/7] fix(calendar): clear CI lint issues in alias PR --- internal/cmd/calendar_alias_test.go | 16 ++++++++-------- internal/cmd/calendar_propose_time.go | 6 +++--- internal/cmd/calendar_respond.go | 6 +++--- internal/cmd/calendar_search.go | 2 +- internal/cmd/calendar_time.go | 3 ++- internal/config/calendar_aliases.go | 1 + internal/config/calendar_aliases_test.go | 8 ++++++-- 7 files changed, 24 insertions(+), 18 deletions(-) diff --git a/internal/cmd/calendar_alias_test.go b/internal/cmd/calendar_alias_test.go index ada5b51f..cbfe2455 100644 --- a/internal/cmd/calendar_alias_test.go +++ b/internal/cmd/calendar_alias_test.go @@ -26,22 +26,22 @@ func TestCalendarAliasSetListUnset_JSON(t *testing.T) { // set _ = captureStdout(t, func() { - if err := runKong(t, &CalendarAliasSetCmd{}, []string{"family", "3656f8abc123@group.calendar.google.com"}, ctx, &RootFlags{}); err != nil { - t.Fatalf("set: %v", err) + if runErr := runKong(t, &CalendarAliasSetCmd{}, []string{"family", "3656f8abc123@group.calendar.google.com"}, ctx, &RootFlags{}); runErr != nil { + t.Fatalf("set: %v", runErr) } }) // list out := captureStdout(t, func() { - if err := runKong(t, &CalendarAliasListCmd{}, []string{}, ctx, &RootFlags{}); err != nil { - t.Fatalf("list: %v", err) + if runErr := runKong(t, &CalendarAliasListCmd{}, []string{}, ctx, &RootFlags{}); runErr != nil { + t.Fatalf("list: %v", runErr) } }) var listResp struct { Aliases map[string]string `json:"aliases"` } - if err := json.Unmarshal([]byte(out), &listResp); err != nil { - t.Fatalf("list json: %v", err) + if unmarshalErr := json.Unmarshal([]byte(out), &listResp); unmarshalErr != nil { + t.Fatalf("list json: %v", unmarshalErr) } if listResp.Aliases["family"] != "3656f8abc123@group.calendar.google.com" { t.Fatalf("unexpected aliases: %#v", listResp.Aliases) @@ -49,8 +49,8 @@ func TestCalendarAliasSetListUnset_JSON(t *testing.T) { // unset _ = captureStdout(t, func() { - if err := runKong(t, &CalendarAliasUnsetCmd{}, []string{"family"}, ctx, &RootFlags{}); err != nil { - t.Fatalf("unset: %v", err) + if runErr := runKong(t, &CalendarAliasUnsetCmd{}, []string{"family"}, ctx, &RootFlags{}); runErr != nil { + t.Fatalf("unset: %v", runErr) } }) diff --git a/internal/cmd/calendar_propose_time.go b/internal/cmd/calendar_propose_time.go index 389c0f3b..b4dcfc0f 100644 --- a/internal/cmd/calendar_propose_time.go +++ b/internal/cmd/calendar_propose_time.go @@ -53,15 +53,15 @@ func (c *CalendarProposeTimeCmd) Run(ctx context.Context, flags *RootFlags) erro proposeURL := "https://calendar.google.com/calendar/u/0/r/proposetime/" + encoded // Avoid touching auth/keyring and avoid mutating the event in dry-run mode. - if err := dryRunExit(ctx, flags, "calendar.propose_time", map[string]any{ + if dryRunErr := dryRunExit(ctx, flags, "calendar.propose_time", map[string]any{ "calendar_id": calendarID, "event_id": eventID, "propose_url": proposeURL, "open": c.Open, "decline": decline, "comment": strings.TrimSpace(c.Comment), - }); err != nil { - return err + }); dryRunErr != nil { + return dryRunErr } account, err := requireAccount(flags) diff --git a/internal/cmd/calendar_respond.go b/internal/cmd/calendar_respond.go index 3ecf11a1..0d325b64 100644 --- a/internal/cmd/calendar_respond.go +++ b/internal/cmd/calendar_respond.go @@ -47,13 +47,13 @@ func (c *CalendarRespondCmd) Run(ctx context.Context, flags *RootFlags) error { return fmt.Errorf("invalid status %q; must be one of: %s", status, strings.Join(validStatuses, ", ")) } - if err := dryRunExit(ctx, flags, "calendar.respond", map[string]any{ + if dryRunErr := dryRunExit(ctx, flags, "calendar.respond", map[string]any{ "calendar_id": calendarID, "event_id": eventID, "status": status, "comment": strings.TrimSpace(c.Comment), - }); err != nil { - return err + }); dryRunErr != nil { + return dryRunErr } account, err := requireAccount(flags) diff --git a/internal/cmd/calendar_search.go b/internal/cmd/calendar_search.go index edf71a11..08c8a766 100644 --- a/internal/cmd/calendar_search.go +++ b/internal/cmd/calendar_search.go @@ -32,7 +32,7 @@ func (c *CalendarSearchCmd) Run(ctx context.Context, flags *RootFlags) error { calendarID := c.CalendarID if calendarID == "" { - calendarID = "primary" + calendarID = primaryCalendarID } else { resolved, resolveErr := resolveCalendarAliasID(calendarID) if resolveErr != nil { diff --git a/internal/cmd/calendar_time.go b/internal/cmd/calendar_time.go index 031661e9..69972a7c 100644 --- a/internal/cmd/calendar_time.go +++ b/internal/cmd/calendar_time.go @@ -44,10 +44,11 @@ func (c *CalendarTimeCmd) Run(ctx context.Context, flags *RootFlags) error { return err } - calendarID, resolveErr := resolveCalendarID(ctx, svc, calendarID) + resolvedCalendarID, resolveErr := resolveCalendarID(ctx, svc, calendarID) if resolveErr != nil { return resolveErr } + calendarID = resolvedCalendarID tz, loc, err = getCalendarLocation(ctx, svc, calendarID) if err != nil { return err diff --git a/internal/config/calendar_aliases.go b/internal/config/calendar_aliases.go index 11d7ae2a..aaef06ea 100644 --- a/internal/config/calendar_aliases.go +++ b/internal/config/calendar_aliases.go @@ -39,6 +39,7 @@ func ResolveCalendarID(calendarID string) (string, error) { if err != nil { return "", err } + if ok { return resolved, nil } diff --git a/internal/config/calendar_aliases_test.go b/internal/config/calendar_aliases_test.go index f57aac2d..c66b133b 100644 --- a/internal/config/calendar_aliases_test.go +++ b/internal/config/calendar_aliases_test.go @@ -52,6 +52,7 @@ func TestResolveCalendarID(t *testing.T) { if err != nil { t.Fatalf("resolve empty: %v", err) } + if resolved != "primary" { t.Fatalf("expected primary for empty, got %q", resolved) } @@ -61,19 +62,21 @@ func TestResolveCalendarID(t *testing.T) { if err != nil { t.Fatalf("resolve non-alias: %v", err) } + if resolved != "some-calendar-id@group.calendar.google.com" { t.Fatalf("expected unchanged, got %q", resolved) } // Set alias and resolve - if err := SetCalendarAlias("work", "work-calendar@group.calendar.google.com"); err != nil { - t.Fatalf("set alias: %v", err) + if setErr := SetCalendarAlias("work", "work-calendar@group.calendar.google.com"); setErr != nil { + t.Fatalf("set alias: %v", setErr) } resolved, err = ResolveCalendarID("work") if err != nil { t.Fatalf("resolve alias: %v", err) } + if resolved != "work-calendar@group.calendar.google.com" { t.Fatalf("expected resolved alias, got %q", resolved) } @@ -83,6 +86,7 @@ func TestResolveCalendarID(t *testing.T) { if err != nil { t.Fatalf("resolve uppercase alias: %v", err) } + if resolved != "work-calendar@group.calendar.google.com" { t.Fatalf("expected resolved alias for uppercase, got %q", resolved) }