diff --git a/internal/cmd/calendar.go b/internal/cmd/calendar.go index ac41aac6..3da47d9d 100644 --- a/internal/cmd/calendar.go +++ b/internal/cmd/calendar.go @@ -15,6 +15,7 @@ import ( type CalendarCmd struct { Calendars CalendarCalendarsCmd `cmd:"" name:"calendars" help:"List calendars"` ACL CalendarAclCmd `cmd:"" name:"acl" aliases:"permissions,perms" help:"List calendar ACL"` + Alias CalendarAliasCmd `cmd:"" name:"alias" help:"Manage calendar aliases"` Events CalendarEventsCmd `cmd:"" name:"events" aliases:"list,ls" help:"List events from a calendar or all calendars"` Event CalendarEventCmd `cmd:"" name:"event" aliases:"get,info,show" help:"Get event"` Create CalendarCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create an event"` @@ -238,12 +239,26 @@ func (c *CalendarEventsCmd) Run(ctx context.Context, flags *RootFlags) error { if !c.All && calendarID == "" && len(calInputs) == 0 { calendarID = primaryCalendarID } + if !c.All && calendarID != "" { + resolved, resolveErr := resolveCalendarAliasID(calendarID) + if resolveErr != nil { + return resolveErr + } + calendarID = resolved + } + for i, input := range calInputs { + resolved, resolveErr := resolveCalendarAliasID(input) + if resolveErr != nil { + return resolveErr + } + calInputs[i] = resolved + } svc, err := newCalendarService(ctx, account) if err != nil { return err } - if !c.All { + if !c.All && calendarID != "" { calendarID, err = resolveCalendarID(ctx, svc, calendarID) if err != nil { return err @@ -293,11 +308,11 @@ func (c *CalendarEventCmd) Run(ctx context.Context, flags *RootFlags) error { if err != nil { return err } - calendarID := strings.TrimSpace(c.CalendarID) - eventID := normalizeCalendarEventID(c.EventID) - if calendarID == "" { - return usage("empty calendarId") + calendarID, err := resolveCalendarAliasID(c.CalendarID) + if err != nil { + return err } + eventID := normalizeCalendarEventID(c.EventID) if eventID == "" { return usage("empty eventId") } diff --git a/internal/cmd/calendar_alias.go b/internal/cmd/calendar_alias.go new file mode 100644 index 00000000..6d1bc09a --- /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(ctx, 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(ctx, 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(ctx, 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..cbfe2455 --- /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 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 runErr := runKong(t, &CalendarAliasListCmd{}, []string{}, ctx, &RootFlags{}); runErr != nil { + t.Fatalf("list: %v", runErr) + } + }) + var listResp struct { + Aliases map[string]string `json:"aliases"` + } + 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) + } + + // unset + _ = captureStdout(t, func() { + if runErr := runKong(t, &CalendarAliasUnsetCmd{}, []string{"family"}, ctx, &RootFlags{}); runErr != nil { + t.Fatalf("unset: %v", runErr) + } + }) + + // 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 TestResolveCalendarAliasID_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 := resolveCalendarAliasID(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) + } + }) + } +} diff --git a/internal/cmd/calendar_conflicts.go b/internal/cmd/calendar_conflicts.go index 9ca8286a..902efc46 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 := resolveCalendarAliasID(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_edit.go b/internal/cmd/calendar_edit.go index 37c6dfc8..39ed8ced 100644 --- a/internal/cmd/calendar_edit.go +++ b/internal/cmd/calendar_edit.go @@ -53,9 +53,9 @@ type CalendarCreateCmd struct { func (c *CalendarCreateCmd) Run(ctx context.Context, flags *RootFlags) error { u := ui.FromContext(ctx) - calendarID := strings.TrimSpace(c.CalendarID) - if calendarID == "" { - return usage("empty calendarId") + calendarID, err := resolveCalendarAliasID(c.CalendarID) + if err != nil { + return err } eventType, err := c.resolveCreateEventType() @@ -349,11 +349,11 @@ type CalendarUpdateCmd struct { func (c *CalendarUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error { u := ui.FromContext(ctx) - calendarID := strings.TrimSpace(c.CalendarID) - eventID := normalizeCalendarEventID(c.EventID) - if calendarID == "" { - return usage("empty calendarId") + calendarID, err := resolveCalendarAliasID(c.CalendarID) + if err != nil { + return err } + eventID := normalizeCalendarEventID(c.EventID) if eventID == "" { return usage("empty eventId") } @@ -864,11 +864,11 @@ type CalendarDeleteCmd struct { func (c *CalendarDeleteCmd) Run(ctx context.Context, flags *RootFlags) error { u := ui.FromContext(ctx) - calendarID := strings.TrimSpace(c.CalendarID) - eventID := normalizeCalendarEventID(c.EventID) - if calendarID == "" { - return usage("empty calendarId") + calendarID, err := resolveCalendarAliasID(c.CalendarID) + if err != nil { + return err } + eventID := normalizeCalendarEventID(c.EventID) if eventID == "" { return usage("empty eventId") } diff --git a/internal/cmd/calendar_focus_time.go b/internal/cmd/calendar_focus_time.go index 4c704950..311c8132 100644 --- a/internal/cmd/calendar_focus_time.go +++ b/internal/cmd/calendar_focus_time.go @@ -25,7 +25,10 @@ type CalendarFocusTimeCmd struct { func (c *CalendarFocusTimeCmd) Run(ctx context.Context, flags *RootFlags) error { u := ui.FromContext(ctx) - calendarID := strings.TrimSpace(c.CalendarID) + calendarID, err := resolveCalendarAliasID(c.CalendarID) + if err != nil { + return err + } autoDeclineMode, err := validateAutoDeclineMode(c.AutoDecline) if err != nil { return err diff --git a/internal/cmd/calendar_freebusy.go b/internal/cmd/calendar_freebusy.go index 8ad2bfa3..acff77d6 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 := resolveCalendarAliasID(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_ooo.go b/internal/cmd/calendar_ooo.go index 728775ec..b42f8887 100644 --- a/internal/cmd/calendar_ooo.go +++ b/internal/cmd/calendar_ooo.go @@ -23,7 +23,10 @@ type CalendarOOOCmd struct { func (c *CalendarOOOCmd) Run(ctx context.Context, flags *RootFlags) error { u := ui.FromContext(ctx) - calendarID := strings.TrimSpace(c.CalendarID) + calendarID, err := resolveCalendarAliasID(c.CalendarID) + if err != nil { + return err + } autoDeclineMode, err := validateAutoDeclineMode(c.AutoDecline) if err != nil { return err diff --git a/internal/cmd/calendar_propose_time.go b/internal/cmd/calendar_propose_time.go index b378d92b..b4dcfc0f 100644 --- a/internal/cmd/calendar_propose_time.go +++ b/internal/cmd/calendar_propose_time.go @@ -34,11 +34,11 @@ type CalendarProposeTimeCmd struct { func (c *CalendarProposeTimeCmd) Run(ctx context.Context, flags *RootFlags) error { u := ui.FromContext(ctx) - calendarID := strings.TrimSpace(c.CalendarID) - eventID := normalizeCalendarEventID(c.EventID) - if calendarID == "" { - return usage("empty calendarId") + calendarID, err := resolveCalendarAliasID(c.CalendarID) + if err != nil { + return err } + eventID := normalizeCalendarEventID(c.EventID) if eventID == "" { return usage("empty eventId") } @@ -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 fef83162..0d325b64 100644 --- a/internal/cmd/calendar_respond.go +++ b/internal/cmd/calendar_respond.go @@ -22,11 +22,11 @@ type CalendarRespondCmd struct { func (c *CalendarRespondCmd) Run(ctx context.Context, flags *RootFlags) error { u := ui.FromContext(ctx) - calendarID := strings.TrimSpace(c.CalendarID) - eventID := normalizeCalendarEventID(c.EventID) - if calendarID == "" { - return usage("empty calendarId") + calendarID, err := resolveCalendarAliasID(c.CalendarID) + if err != nil { + return err } + eventID := normalizeCalendarEventID(c.EventID) if eventID == "" { return usage("empty eventId") } @@ -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 f881fd2b..08c8a766 100644 --- a/internal/cmd/calendar_search.go +++ b/internal/cmd/calendar_search.go @@ -30,11 +30,22 @@ func (c *CalendarSearchCmd) Run(ctx context.Context, flags *RootFlags) error { return fmt.Errorf("search query cannot be empty") } + calendarID := c.CalendarID + if calendarID == "" { + calendarID = primaryCalendarID + } else { + resolved, resolveErr := resolveCalendarAliasID(calendarID) + if resolveErr != nil { + return resolveErr + } + calendarID = resolved + } + svc, err := newCalendarService(ctx, account) if err != nil { return err } - calendarID, err := resolveCalendarID(ctx, svc, strings.TrimSpace(c.CalendarID)) + calendarID, err = resolveCalendarID(ctx, svc, calendarID) if err != nil { return err } diff --git a/internal/cmd/calendar_time.go b/internal/cmd/calendar_time.go index 413c62c9..69972a7c 100644 --- a/internal/cmd/calendar_time.go +++ b/internal/cmd/calendar_time.go @@ -20,6 +20,10 @@ func (c *CalendarTimeCmd) Run(ctx context.Context, flags *RootFlags) error { if err != nil { return err } + calendarID, err := resolveCalendarAliasID(c.CalendarID) + if err != nil { + return err + } var tz string var loc *time.Location @@ -40,10 +44,11 @@ func (c *CalendarTimeCmd) Run(ctx context.Context, flags *RootFlags) error { return err } - calendarID, resolveErr := resolveCalendarID(ctx, svc, c.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/cmd/calendar_util.go b/internal/cmd/calendar_util.go index 4be2931a..23f34c4c 100644 --- a/internal/cmd/calendar_util.go +++ b/internal/cmd/calendar_util.go @@ -1,7 +1,27 @@ 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 != "" } + +func resolveCalendarAliasID(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 +} diff --git a/internal/cmd/calendar_working_location.go b/internal/cmd/calendar_working_location.go index 13c863af..b3dc650a 100644 --- a/internal/cmd/calendar_working_location.go +++ b/internal/cmd/calendar_working_location.go @@ -26,7 +26,10 @@ type CalendarWorkingLocationCmd struct { func (c *CalendarWorkingLocationCmd) Run(ctx context.Context, flags *RootFlags) error { u := ui.FromContext(ctx) - calendarID := strings.TrimSpace(c.CalendarID) + calendarID, err := resolveCalendarAliasID(c.CalendarID) + if err != nil { + return err + } props, err := c.buildWorkingLocationProperties() if err != nil { return err diff --git a/internal/config/calendar_aliases.go b/internal/config/calendar_aliases.go new file mode 100644 index 00000000..aaef06ea --- /dev/null +++ b/internal/config/calendar_aliases.go @@ -0,0 +1,105 @@ +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..c66b133b --- /dev/null +++ b/internal/config/calendar_aliases_test.go @@ -0,0 +1,124 @@ +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 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) + } + + // 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) {