Skip to content
Open
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
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 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 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
Loading