diff --git a/CHANGELOG.md b/CHANGELOG.md index 982eef3..49b31d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Auth: add `auth credentials remove` to delete stored OAuth client credentials and associated refresh tokens. (#473) — thanks @yamagucci. - Drive: convert Markdown uploads to Google Docs and strip leading YAML frontmatter by default, with `--keep-frontmatter` to opt out. (#487) — thanks @johnbenjaminlewis. - Slides: add `slides thumbnail` / `slides thumb` to fetch rendered slide thumbnail URLs or download PNG/JPEG images. (#498) — thanks @gianpaj. +- Calendar: add `calendar create-calendar` / `new-calendar` to create secondary calendars with description, timezone, and location. (#455) — thanks @alexknowshtml. - Gmail: add `gmail autoreply` to reply once to matching messages, label the thread for dedupe, and optionally archive/mark read. Includes docs and regression coverage for skip/reply flows. - Gmail: add `gmail messages search --full` to print complete message bodies instead of truncating text output. (#447) — thanks @GodsBoy. - Drive: allow `drive share --role commenter` for comment-only sharing. (#443) — thanks @pavelzak. diff --git a/README.md b/README.md index 074b432..d177818 100644 --- a/README.md +++ b/README.md @@ -745,6 +745,7 @@ Docs: `docs/email-tracking.md` (setup/deploy) + `docs/email-tracking-worker.md` ```bash # Calendars gog calendar calendars +gog calendar create-calendar "Team Calendar" --timezone Europe/London gog calendar acl # List access control rules gog calendar colors # List available event/calendar colors gog calendar time --timezone America/New_York diff --git a/docs/spec.md b/docs/spec.md index 1b52629..21f8283 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -201,6 +201,7 @@ Flag aliases: - `gog drive drives [--max N] [--page TOKEN] [--query Q]` - `gog slides thumbnail [--size small|medium|large] [--format png|jpeg] [--out PATH]` - `gog calendar calendars` +- `gog calendar create-calendar [--description D] [--timezone TZ] [--location L]` - `gog calendar acl ` - `gog calendar events [--cal ID_OR_NAME] [--calendars CSV] [--all] [--from RFC3339] [--to RFC3339] [--max N] [--page TOKEN] [--query Q] [--weekday]` - `gog calendar event|get ` diff --git a/internal/cmd/calendar.go b/internal/cmd/calendar.go index f51bb22..86d896d 100644 --- a/internal/cmd/calendar.go +++ b/internal/cmd/calendar.go @@ -3,6 +3,7 @@ package cmd type CalendarCmd struct { Calendars CalendarCalendarsCmd `cmd:"" name:"calendars" help:"List calendars"` Subscribe CalendarSubscribeCmd `cmd:"" name:"subscribe" aliases:"sub,add-calendar" help:"Add a calendar to your calendar list"` + CreateCalendar CalendarCreateCalendarCmd `cmd:"" name:"create-calendar" aliases:"new-calendar" help:"Create a new secondary calendar"` 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"` diff --git a/internal/cmd/calendar_create_calendar.go b/internal/cmd/calendar_create_calendar.go new file mode 100644 index 0000000..079a864 --- /dev/null +++ b/internal/cmd/calendar_create_calendar.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "google.golang.org/api/calendar/v3" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type CalendarCreateCalendarCmd struct { + Summary string `arg:"" name:"summary" help:"Calendar display name"` + Description string `name:"description" help:"Calendar description"` + TimeZone string `name:"timezone" aliases:"tz" help:"IANA timezone (e.g., America/New_York)"` + Location string `name:"location" help:"Calendar location"` +} + +func (c *CalendarCreateCalendarCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + summary := strings.TrimSpace(c.Summary) + if summary == "" { + return usage("required: calendar name (positional argument)") + } + + account, err := requireAccount(flags) + if err != nil { + return err + } + + cal := &calendar.Calendar{ + Summary: summary, + Description: strings.TrimSpace(c.Description), + TimeZone: strings.TrimSpace(c.TimeZone), + Location: strings.TrimSpace(c.Location), + } + if cal.TimeZone != "" { + if _, tzErr := loadTimezoneLocation(cal.TimeZone); tzErr != nil { + return fmt.Errorf("invalid timezone %q: %w", cal.TimeZone, tzErr) + } + } + + if dryRunErr := dryRunExit(ctx, flags, "calendar.create-calendar", map[string]any{ + "calendar": cal, + }); dryRunErr != nil { + return dryRunErr + } + + svc, err := newCalendarService(ctx, account) + if err != nil { + return err + } + + created, err := svc.Calendars.Insert(cal).Context(ctx).Do() + if err != nil { + return fmt.Errorf("create calendar: %w", err) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"calendar": created}) + } + + u.Out().Printf("id\t%s", created.Id) + u.Out().Printf("summary\t%s", created.Summary) + if created.TimeZone != "" { + u.Out().Printf("timezone\t%s", created.TimeZone) + } + if created.Description != "" { + u.Out().Printf("description\t%s", created.Description) + } + if created.Location != "" { + u.Out().Printf("location\t%s", created.Location) + } + return nil +} diff --git a/internal/cmd/calendar_create_calendar_test.go b/internal/cmd/calendar_create_calendar_test.go new file mode 100644 index 0000000..f14c9ba --- /dev/null +++ b/internal/cmd/calendar_create_calendar_test.go @@ -0,0 +1,153 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "testing" + + "google.golang.org/api/calendar/v3" +) + +func TestCalendarCreateCalendarCmd_RunJSON(t *testing.T) { + origNew := newCalendarService + t.Cleanup(func() { newCalendarService = origNew }) + + var got calendar.Calendar + svc, closeSvc := newCalendarServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/calendar/v3") + if r.Method != http.MethodPost || path != "/calendars" { + http.NotFound(w, r) + return + } + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode request: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "created@example.com", + "summary": got.Summary, + "description": got.Description, + "timeZone": got.TimeZone, + "location": got.Location, + }) + })) + defer closeSvc() + newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil } + + cmd := &CalendarCreateCalendarCmd{ + Summary: "Team Calendar", + Description: "Planning", + TimeZone: "Europe/London", + Location: "London", + } + out := captureStdout(t, func() { + if err := cmd.Run(newCalendarJSONContext(t), &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if got.Summary != "Team Calendar" || got.Description != "Planning" || got.TimeZone != "Europe/London" || got.Location != "London" { + t.Fatalf("unexpected request: %#v", got) + } + var payload struct { + Calendar calendar.Calendar `json:"calendar"` + } + if err := json.Unmarshal([]byte(out), &payload); err != nil { + t.Fatalf("decode output: %v\nout=%q", err, out) + } + if payload.Calendar.Id != "created@example.com" || payload.Calendar.Summary != "Team Calendar" { + t.Fatalf("unexpected output: %#v", payload.Calendar) + } +} + +func TestCalendarCreateCalendarCmd_RunTextIncludesLocation(t *testing.T) { + origNew := newCalendarService + t.Cleanup(func() { newCalendarService = origNew }) + + svc, closeSvc := newCalendarServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/calendar/v3") + if r.Method != http.MethodPost || path != "/calendars" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "created@example.com", + "summary": "Team Calendar", + "timeZone": "UTC", + "location": "Remote", + }) + })) + defer closeSvc() + newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil } + + var outBuf bytes.Buffer + ctx := newCalendarOutputContext(t, &outBuf, io.Discard) + err := (&CalendarCreateCalendarCmd{Summary: "Team Calendar", TimeZone: "UTC"}).Run(ctx, &RootFlags{Account: "a@b.com"}) + if err != nil { + t.Fatalf("Run: %v", err) + } + out := outBuf.String() + for _, want := range []string{"id\tcreated@example.com", "summary\tTeam Calendar", "timezone\tUTC", "location\tRemote"} { + if !strings.Contains(out, want) { + t.Fatalf("expected %q in output, got %q", want, out) + } + } +} + +func TestCalendarCreateCalendarCmd_DryRunDoesNotOpenService(t *testing.T) { + origNew := newCalendarService + t.Cleanup(func() { newCalendarService = origNew }) + + newCalendarService = func(context.Context, string) (*calendar.Service, error) { + t.Fatal("newCalendarService should not be called during dry-run") + return nil, errors.New("unexpected calendar service call") + } + + out := captureStdout(t, func() { + err := (&CalendarCreateCalendarCmd{ + Summary: "Dry Run", + TimeZone: "UTC", + }).Run(newCalendarJSONContext(t), &RootFlags{Account: "a@b.com", DryRun: true}) + var exitErr *ExitError + if !errors.As(err, &exitErr) || exitErr.Code != 0 { + t.Fatalf("expected dry-run exit 0, got %v", err) + } + }) + + var payload struct { + DryRun bool `json:"dry_run"` + Op string `json:"op"` + Request struct { + Calendar calendar.Calendar `json:"calendar"` + } `json:"request"` + } + if err := json.Unmarshal([]byte(out), &payload); err != nil { + t.Fatalf("decode dry-run: %v\nout=%q", err, out) + } + if !payload.DryRun || payload.Op != "calendar.create-calendar" || payload.Request.Calendar.Summary != "Dry Run" { + t.Fatalf("unexpected dry-run output: %#v", payload) + } +} + +func TestCalendarCreateCalendarCmd_InvalidTimezone(t *testing.T) { + origNew := newCalendarService + t.Cleanup(func() { newCalendarService = origNew }) + newCalendarService = func(context.Context, string) (*calendar.Service, error) { + t.Fatal("newCalendarService should not be called for invalid timezone") + return nil, errors.New("unexpected calendar service call") + } + + err := (&CalendarCreateCalendarCmd{ + Summary: "Bad TZ", + TimeZone: "Nope/Zone", + }).Run(newCalendarJSONContext(t), &RootFlags{Account: "a@b.com"}) + if err == nil || !strings.Contains(err.Error(), `invalid timezone "Nope/Zone"`) { + t.Fatalf("expected invalid timezone error, got %v", err) + } +}