feat(calendar): add secondary calendar creation
Adds `gog calendar create-calendar` / `new-calendar` for secondary Google Calendar creation with summary, description, timezone, and location. Landing polish: - removed unrelated `.byline` cache files from the PR branch - validates IANA timezone names locally - dry-run no longer opens the Calendar service - includes location in text output - added request/body, text, dry-run, and validation tests - README/spec/CHANGELOG entries Local verification: - go test ./internal/cmd -run TestCalendarCreateCalendarCmd - make lint - make test - make build - make ci - live Calendar smoke with clawdbot@gmail.com: created secondary calendar, verified summary/timezone/location, deleted it via the same auth path Thanks @alexknowshtml. Co-authored-by: Alex Hillman <alex@indyhall.org> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a1fd0a8333
commit
db3a32432c
@ -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.
|
||||
|
||||
@ -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 <calendarId> # List access control rules
|
||||
gog calendar colors # List available event/calendar colors
|
||||
gog calendar time --timezone America/New_York
|
||||
|
||||
@ -201,6 +201,7 @@ Flag aliases:
|
||||
- `gog drive drives [--max N] [--page TOKEN] [--query Q]`
|
||||
- `gog slides thumbnail <presentationId> <slideId> [--size small|medium|large] [--format png|jpeg] [--out PATH]`
|
||||
- `gog calendar calendars`
|
||||
- `gog calendar create-calendar <summary> [--description D] [--timezone TZ] [--location L]`
|
||||
- `gog calendar acl <calendarId>`
|
||||
- `gog calendar events <calendarId> [--cal ID_OR_NAME] [--calendars CSV] [--all] [--from RFC3339] [--to RFC3339] [--max N] [--page TOKEN] [--query Q] [--weekday]`
|
||||
- `gog calendar event|get <calendarId> <eventId>`
|
||||
|
||||
@ -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"`
|
||||
|
||||
78
internal/cmd/calendar_create_calendar.go
Normal file
78
internal/cmd/calendar_create_calendar.go
Normal file
@ -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
|
||||
}
|
||||
153
internal/cmd/calendar_create_calendar_test.go
Normal file
153
internal/cmd/calendar_create_calendar_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user