Skip to content
Open
25 changes: 20 additions & 5 deletions internal/cmd/calendar.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
Expand Down
108 changes: 108 additions & 0 deletions internal/cmd/calendar_alias.go
Original file line number Diff line number Diff line change
@@ -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
}
158 changes: 158 additions & 0 deletions internal/cmd/calendar_alias_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
14 changes: 12 additions & 2 deletions internal/cmd/calendar_conflicts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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})
}

Expand Down
22 changes: 11 additions & 11 deletions internal/cmd/calendar_edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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")
}
Expand Down
Loading