diff --git a/README.md b/README.md index 1d1856f0..c5641bf6 100644 --- a/README.md +++ b/README.md @@ -669,6 +669,28 @@ gog calendar create \ --attendees "alice@example.com,bob@example.com" \ --location "Zoom" +# Place lookup for locations (requires Places API (New) enabled + API key) +# Google Cloud Console → APIs & Services → Library → enable "Places API (New)". +# Credentials → Create API key (restrict to Places API (New) if possible). +# Store via "gog auth manage" (Places API Key) or: +gog config set places_api_key "..." +# Or use an environment variable (overrides stored keys): +export GOOGLE_PLACES_API_KEY="..." +# Note: config storage is plain text; keychain is recommended. +# Headless/CI: gog auth places-key set --key "..." --store keychain|config +gog calendar create \ + --summary "Coffee" \ + --from 2025-01-15T14:00:00Z \ + --to 2025-01-15T15:00:00Z \ + --location-search "Elysian Coffee Vancouver" + +gog calendar create \ + --summary "Coffee" \ + --from 2025-01-15T14:00:00Z \ + --to 2025-01-15T15:00:00Z \ + --place-id ChIJ... +# Optional locale hints: --place-language en --place-region US + gog calendar update \ --summary "Updated Meeting" \ --from 2025-01-15T11:00:00Z \ diff --git a/docs/spec.md b/docs/spec.md index 589bfe03..51cf2d81 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -134,6 +134,7 @@ Environment: - `GOG_ENABLE_COMMANDS=calendar,tasks` (optional allowlist of top-level commands) - `config.json` can also set `keyring_backend` (JSON5; env vars take precedence) - `config.json` can also set `default_timezone` (IANA name or `UTC`) +- `config.json` can also set `places_api_key` (Google Places API key; env vars take precedence) - `config.json` can also set `account_aliases` for `gog auth alias` (JSON5) - `config.json` can also set `account_clients` (email -> client) and `client_domains` (domain -> client) @@ -150,6 +151,9 @@ Flag aliases: - `gog --client auth credentials ` - `gog auth add [--services user|all|gmail,calendar,classroom,drive,docs,contacts,tasks,sheets,people,groups] [--readonly] [--drive-scope full|readonly|file] [--manual] [--force-consent]` - `gog auth services [--markdown]` +- `gog auth places-key set --key [--store keychain|config]` +- `gog auth places-key status` +- `gog auth places-key clear [--store keychain|config|all]` - `gog auth keep --key ` (Google Keep; Workspace only) - `gog auth list` - `gog auth alias list` @@ -184,8 +188,8 @@ Flag aliases: - `gog calendar events [--from RFC3339] [--to RFC3339] [--max N] [--page TOKEN] [--query Q] [--weekday]` - `gog calendar event|get ` - `GOG_CALENDAR_WEEKDAY=1` defaults `--weekday` for `gog calendar events` -- `gog calendar create --summary S --from DT --to DT [--description D] [--location L] [--attendees a@b.com,c@d.com] [--all-day] [--event-type TYPE]` -- `gog calendar update [--summary S] [--from DT] [--to DT] [--description D] [--location L] [--attendees ...] [--add-attendee ...] [--all-day] [--event-type TYPE]` +- `gog calendar create --summary S --from DT --to DT [--description D] [--location L|--location-search Q|--place-id ID] [--place-language LANG] [--place-region REGION] [--attendees a@b.com,c@d.com] [--all-day] [--event-type TYPE]` +- `gog calendar update [--summary S] [--from DT] [--to DT] [--description D] [--location L|--location-search Q|--place-id ID] [--place-language LANG] [--place-region REGION] [--attendees ...] [--add-attendee ...] [--all-day] [--event-type TYPE]` - `gog calendar delete ` - `gog calendar freebusy --from RFC3339 --to RFC3339` - `gog calendar respond --status accepted|declined|tentative [--send-updates all|none|externalOnly]` diff --git a/go.mod b/go.mod index 94da9b50..1e132e71 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,14 @@ module github.com/steipete/gogcli -go 1.25 +go 1.25.5 require ( github.com/99designs/keyring v1.2.2 github.com/alecthomas/kong v1.13.0 github.com/muesli/termenv v0.16.0 + github.com/steipete/goplaces v0.2.1 github.com/yosuke-furukawa/json5 v0.1.1 + golang.org/x/net v0.49.0 golang.org/x/oauth2 v0.34.0 golang.org/x/term v0.39.0 google.golang.org/api v0.260.0 @@ -40,7 +42,6 @@ require ( go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect golang.org/x/crypto v0.47.0 // indirect - golang.org/x/net v0.49.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 // indirect diff --git a/go.sum b/go.sum index cf0fb7a2..726bd61d 100644 --- a/go.sum +++ b/go.sum @@ -70,6 +70,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/steipete/goplaces v0.2.1 h1:d1yg+K84d7VlRPFYW0vGpfFNjNJYW/qTUIHaIaKQ6cs= +github.com/steipete/goplaces v0.2.1/go.mod h1:TwmCIa+afuSL2OdCOJuSf/L8afmYOa5zrl4GQ1jTT1M= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index a615c208..67b9e612 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -15,6 +15,7 @@ import ( "github.com/steipete/gogcli/internal/config" "github.com/steipete/gogcli/internal/googleauth" "github.com/steipete/gogcli/internal/outfmt" + placescfg "github.com/steipete/gogcli/internal/places" "github.com/steipete/gogcli/internal/secrets" "github.com/steipete/gogcli/internal/ui" ) @@ -60,6 +61,7 @@ type AuthCmd struct { Remove AuthRemoveCmd `cmd:"" name:"remove" help:"Remove a stored refresh token"` Tokens AuthTokensCmd `cmd:"" name:"tokens" help:"Manage stored refresh tokens"` Manage AuthManageCmd `cmd:"" name:"manage" help:"Open accounts manager in browser" aliases:"login"` + PlacesKey AuthPlacesKeyCmd `cmd:"" name:"places-key" help:"Manage Places API key for calendar lookups"` ServiceAcct AuthServiceAccountCmd `cmd:"" name:"service-account" help:"Configure service account (Workspace only; domain-wide delegation)"` Keep AuthKeepCmd `cmd:"" name:"keep" help:"Configure service account for Google Keep (Workspace only)"` } @@ -982,6 +984,145 @@ func (c *AuthManageCmd) Run(ctx context.Context) error { }) } +type AuthPlacesKeyCmd struct { + Status AuthPlacesKeyStatusCmd `cmd:"" name:"status" help:"Show Places API key status"` + Set AuthPlacesKeySetCmd `cmd:"" name:"set" help:"Store Places API key"` + Clear AuthPlacesKeyClearCmd `cmd:"" name:"clear" help:"Remove stored Places API key"` +} + +type AuthPlacesKeyStatusCmd struct{} + +func (c *AuthPlacesKeyStatusCmd) Run(ctx context.Context) error { + u := ui.FromContext(ctx) + + state, err := placescfg.LoadAPIKey() + if err != nil { + return err + } + configured := strings.TrimSpace(state.Key) != "" + hint := placescfg.MaskAPIKey(state.Key) + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "configured": configured, + "source": state.Source, + "hint": hint, + }) + } + + u.Out().Printf("configured\t%t", configured) + u.Out().Printf("source\t%s", state.Source) + if hint != "" { + u.Out().Printf("hint\t%s", hint) + } + return nil +} + +type AuthPlacesKeySetCmd struct { + Key string `name:"key" help:"Places API key or '-' to read from stdin"` + Store string `name:"store" help:"Storage: keychain or config" default:"keychain"` +} + +func (c *AuthPlacesKeySetCmd) Run(ctx context.Context) error { + u := ui.FromContext(ctx) + + key := strings.TrimSpace(c.Key) + if key == "" { + return usage("missing --key (use --key - to read from stdin)") + } + if key == "-" { + b, err := io.ReadAll(os.Stdin) + if err != nil { + return err + } + key = strings.TrimSpace(string(b)) + if key == "" { + return usage("empty key from stdin") + } + } + + store := strings.ToLower(strings.TrimSpace(c.Store)) + if store == "" { + store = "keychain" + } + + switch store { + case "keychain": + if err := ensureKeychainAccessIfNeeded(); err != nil { + return err + } + if err := placescfg.SaveAPIKeyKeychain(key); err != nil { + return err + } + case "config": + if err := placescfg.SaveAPIKeyConfig(key); err != nil { + return err + } + default: + return usage("invalid --store (use keychain or config)") + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "saved": true, + "store": store, + "source": store, + }) + } + u.Out().Printf("saved\ttrue") + u.Out().Printf("store\t%s", store) + return nil +} + +type AuthPlacesKeyClearCmd struct { + Store string `name:"store" help:"Storage to clear: keychain, config, all" default:"all"` +} + +func (c *AuthPlacesKeyClearCmd) Run(ctx context.Context) error { + u := ui.FromContext(ctx) + + store := strings.ToLower(strings.TrimSpace(c.Store)) + if store == "" { + store = "all" + } + + switch store { + case "keychain": + if err := ensureKeychainAccessIfNeeded(); err != nil { + return err + } + if err := placescfg.ClearAPIKeyKeychain(); err != nil { + return err + } + case "config": + if err := placescfg.ClearAPIKeyConfig(); err != nil { + return err + } + case "all": + if err := ensureKeychainAccessIfNeeded(); err != nil { + return err + } + if err := placescfg.ClearAPIKeyKeychain(); err != nil { + return err + } + if err := placescfg.ClearAPIKeyConfig(); err != nil { + return err + } + default: + return usage("invalid --store (use keychain, config, or all)") + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(os.Stdout, map[string]any{ + "cleared": true, + "store": store, + }) + } + u.Out().Printf("cleared\ttrue") + u.Out().Printf("store\t%s", store) + return nil +} + type AuthKeepCmd struct { Email string `arg:"" name:"email" help:"Email to impersonate when using Keep"` Key string `name:"key" required:"" help:"Path to service account JSON key file"` diff --git a/internal/cmd/auth_places_key_test.go b/internal/cmd/auth_places_key_test.go new file mode 100644 index 00000000..a7f8be10 --- /dev/null +++ b/internal/cmd/auth_places_key_test.go @@ -0,0 +1,88 @@ +package cmd + +import ( + "encoding/json" + "testing" + + "github.com/steipete/gogcli/internal/config" +) + +func TestAuthPlacesKeyConfigFlow(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("GOOGLE_PLACES_API_KEY", "") + + setOut := captureStdout(t, func() { + _ = captureStderr(t, func() { + if err := Execute([]string{"--json", "auth", "places-key", "set", "--key", "places-123", "--store", "config"}); err != nil { + t.Fatalf("Execute set: %v", err) + } + }) + }) + + var setResp struct { + Saved bool `json:"saved"` + } + if err := json.Unmarshal([]byte(setOut), &setResp); err != nil { + t.Fatalf("set json parse: %v\nout=%q", err, setOut) + } + if !setResp.Saved { + t.Fatalf("expected saved true") + } + + cfg, err := config.ReadConfig() + if err != nil { + t.Fatalf("read config: %v", err) + } + if cfg.PlacesAPIKey != "places-123" { + t.Fatalf("expected places api key stored, got %q", cfg.PlacesAPIKey) + } + + statusOut := captureStdout(t, func() { + _ = captureStderr(t, func() { + if execErr := Execute([]string{"--json", "auth", "places-key", "status"}); execErr != nil { + t.Fatalf("Execute status: %v", execErr) + } + }) + }) + + var status struct { + Configured bool `json:"configured"` + Source string `json:"source"` + } + if unmarshalErr := json.Unmarshal([]byte(statusOut), &status); unmarshalErr != nil { + t.Fatalf("status json parse: %v\nout=%q", unmarshalErr, statusOut) + } + if !status.Configured { + t.Fatalf("expected configured true") + } + if status.Source != "config" { + t.Fatalf("expected source config, got %q", status.Source) + } + + clearOut := captureStdout(t, func() { + _ = captureStderr(t, func() { + if execErr := Execute([]string{"--json", "auth", "places-key", "clear", "--store", "config"}); execErr != nil { + t.Fatalf("Execute clear: %v", execErr) + } + }) + }) + + var clearResp struct { + Cleared bool `json:"cleared"` + } + if unmarshalErr := json.Unmarshal([]byte(clearOut), &clearResp); unmarshalErr != nil { + t.Fatalf("clear json parse: %v\nout=%q", unmarshalErr, clearOut) + } + if !clearResp.Cleared { + t.Fatalf("expected cleared true") + } + + cfg, err = config.ReadConfig() + if err != nil { + t.Fatalf("read config: %v", err) + } + if cfg.PlacesAPIKey != "" { + t.Fatalf("expected places api key cleared, got %q", cfg.PlacesAPIKey) + } +} diff --git a/internal/cmd/calendar_build.go b/internal/cmd/calendar_build.go index eb353748..00025f75 100644 --- a/internal/cmd/calendar_build.go +++ b/internal/cmd/calendar_build.go @@ -232,3 +232,55 @@ func buildExtendedProperties(privateProps, sharedProps []string) *calendar.Event return props } + +func mergeExtendedProperties(base *calendar.EventExtendedProperties, addPrivate, addShared map[string]string) *calendar.EventExtendedProperties { + if base == nil && len(addPrivate) == 0 && len(addShared) == 0 { + return nil + } + if base == nil { + base = &calendar.EventExtendedProperties{} + } + if len(addPrivate) > 0 { + if base.Private == nil { + base.Private = make(map[string]string) + } + for k, v := range addPrivate { + if strings.TrimSpace(k) == "" { + continue + } + base.Private[k] = v + } + } + if len(addShared) > 0 { + if base.Shared == nil { + base.Shared = make(map[string]string) + } + for k, v := range addShared { + if strings.TrimSpace(k) == "" { + continue + } + base.Shared[k] = v + } + } + return base +} + +func cloneExtendedProperties(props *calendar.EventExtendedProperties) *calendar.EventExtendedProperties { + if props == nil { + return nil + } + clone := &calendar.EventExtendedProperties{} + if len(props.Private) > 0 { + clone.Private = make(map[string]string, len(props.Private)) + for k, v := range props.Private { + clone.Private[k] = v + } + } + if len(props.Shared) > 0 { + clone.Shared = make(map[string]string, len(props.Shared)) + for k, v := range props.Shared { + clone.Shared[k] = v + } + } + return clone +} diff --git a/internal/cmd/calendar_edit.go b/internal/cmd/calendar_edit.go index 0c364c8d..88f80a66 100644 --- a/internal/cmd/calendar_edit.go +++ b/internal/cmd/calendar_edit.go @@ -20,6 +20,10 @@ type CalendarCreateCmd struct { To string `name:"to" help:"End time (RFC3339)"` Description string `name:"description" help:"Description"` Location string `name:"location" help:"Location"` + LocationSearch string `name:"location-search" help:"Search Google Places for a location (requires GOOGLE_PLACES_API_KEY)"` + PlaceID string `name:"place-id" help:"Google Places ID for the location (requires GOOGLE_PLACES_API_KEY)"` + PlaceLanguage string `name:"place-language" help:"Places API language code (e.g., en, en-US)"` + PlaceRegion string `name:"place-region" help:"Places API region code (e.g., US, DE)"` Attendees string `name:"attendees" help:"Comma-separated attendee emails"` AllDay bool `name:"all-day" help:"All-day event (use date-only in --from/--to)"` Recurrence []string `name:"rrule" help:"Recurrence rules (e.g., 'RRULE:FREQ=MONTHLY;BYMONTHDAY=11'). Can be repeated."` @@ -102,6 +106,31 @@ func (c *CalendarCreateCmd) Run(ctx context.Context, flags *RootFlags) error { } transparency = applyEventTypeTransparencyDefault(transparency, eventType) + locationSet := strings.TrimSpace(c.Location) != "" + if validateErr := validatePlaceLocationFlags(locationSet, strings.TrimSpace(c.LocationSearch) != "", c.LocationSearch, strings.TrimSpace(c.PlaceID) != "", c.PlaceID); validateErr != nil { + return validateErr + } + + place, err := resolveCalendarPlace(ctx, placeLookup{ + SearchText: c.LocationSearch, + PlaceID: c.PlaceID, + Language: c.PlaceLanguage, + Region: c.PlaceRegion, + }) + if err != nil { + return err + } + if place != nil { + resolvedLocation, formatErr := formatPlaceLocation(place) + if formatErr != nil { + return formatErr + } + c.Location = resolvedLocation + if u != nil { + u.Err().Printf("Using place: %s (%s)\n", placeLabel(place), place.PlaceID) + } + } + svc, err := newCalendarService(ctx, account) if err != nil { return err @@ -121,7 +150,7 @@ func (c *CalendarCreateCmd) Run(ctx context.Context, flags *RootFlags) error { Transparency: transparency, ConferenceData: buildConferenceData(c.WithMeet), Attachments: buildAttachments(c.Attachments), - ExtendedProperties: buildExtendedProperties(c.PrivateProps, c.SharedProps), + ExtendedProperties: mergeExtendedProperties(buildExtendedProperties(c.PrivateProps, c.SharedProps), placePrivateProps(place), nil), } if c.GuestsCanInviteOthers != nil { event.GuestsCanInviteOthers = c.GuestsCanInviteOthers @@ -302,6 +331,10 @@ type CalendarUpdateCmd struct { To string `name:"to" help:"New end time (RFC3339; set empty to clear)"` Description string `name:"description" help:"New description (set empty to clear)"` Location string `name:"location" help:"New location (set empty to clear)"` + LocationSearch string `name:"location-search" help:"Search Google Places for a location (requires GOOGLE_PLACES_API_KEY)"` + PlaceID string `name:"place-id" help:"Google Places ID for the location (requires GOOGLE_PLACES_API_KEY)"` + PlaceLanguage string `name:"place-language" help:"Places API language code (e.g., en, en-US)"` + PlaceRegion string `name:"place-region" help:"Places API region code (e.g., US, DE)"` Attendees string `name:"attendees" help:"Comma-separated attendee emails (replaces all; set empty to clear)"` AddAttendee string `name:"add-attendee" help:"Comma-separated attendee emails to add (preserves existing attendees)"` AllDay bool `name:"all-day" help:"All-day event (use date-only in --from/--to)"` @@ -376,7 +409,36 @@ func (c *CalendarUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags * return usage("cannot use both --attendees and --add-attendee; use --attendees to replace all, or --add-attendee to add") } - patch, changed, err := c.buildUpdatePatch(kctx) + locationSet := flagProvided(kctx, "location") + if validateErr := validatePlaceLocationFlags(locationSet, flagProvided(kctx, "location-search"), c.LocationSearch, flagProvided(kctx, "place-id"), c.PlaceID); validateErr != nil { + return validateErr + } + + place, err := resolveCalendarPlace(ctx, placeLookup{ + SearchText: c.LocationSearch, + PlaceID: c.PlaceID, + Language: c.PlaceLanguage, + Region: c.PlaceRegion, + }) + if err != nil { + return err + } + + forceLocation := false + placeProps := placePrivateProps(place) + if place != nil { + resolvedLocation, formatErr := formatPlaceLocation(place) + if formatErr != nil { + return formatErr + } + c.Location = resolvedLocation + forceLocation = true + if u != nil { + u.Err().Printf("Using place: %s (%s)\n", placeLabel(place), place.PlaceID) + } + } + + patch, changed, err := c.buildUpdatePatch(kctx, placeProps, forceLocation) if err != nil { return err } @@ -395,14 +457,20 @@ func (c *CalendarUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags * return err } - // For --add-attendee, fetch current event to preserve existing attendees with metadata. - if wantsAddAttendee { + needsPropMerge := len(placeProps) > 0 && !flagProvided(kctx, "private-prop") && !flagProvided(kctx, "shared-prop") + if wantsAddAttendee || needsPropMerge { existing, getErr := svc.Events.Get(calendarID, eventID).Context(ctx).Do() if getErr != nil { return fmt.Errorf("failed to fetch current event: %w", getErr) } - patch.Attendees = mergeAttendees(existing.Attendees, c.AddAttendee) - changed = true + if wantsAddAttendee { + patch.Attendees = mergeAttendees(existing.Attendees, c.AddAttendee) + changed = true + } + if needsPropMerge { + patch.ExtendedProperties = mergeExtendedProperties(cloneExtendedProperties(existing.ExtendedProperties), placeProps, nil) + changed = true + } } if !changed { @@ -431,7 +499,7 @@ func (c *CalendarUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags * return nil } -func (c *CalendarUpdateCmd) buildUpdatePatch(kctx *kong.Context) (*calendar.Event, bool, error) { +func (c *CalendarUpdateCmd) buildUpdatePatch(kctx *kong.Context, placeProps map[string]string, forceLocation bool) (*calendar.Event, bool, error) { patch := &calendar.Event{} changed := false @@ -440,7 +508,7 @@ func (c *CalendarUpdateCmd) buildUpdatePatch(kctx *kong.Context) (*calendar.Even return nil, false, err } - if c.applyTextFields(kctx, patch) { + if c.applyTextFields(kctx, patch, forceLocation) { changed = true } @@ -480,7 +548,7 @@ func (c *CalendarUpdateCmd) buildUpdatePatch(kctx *kong.Context) (*calendar.Even changed = true } - if c.applyExtendedProperties(kctx, patch) { + if c.applyExtendedProperties(kctx, patch, placeProps) { changed = true } @@ -506,7 +574,7 @@ func (c *CalendarUpdateCmd) resolveUpdateEventType(kctx *kong.Context) (string, return eventType, eventType != "", focusFlags, oooFlags, workingFlags, nil } -func (c *CalendarUpdateCmd) applyTextFields(kctx *kong.Context, patch *calendar.Event) bool { +func (c *CalendarUpdateCmd) applyTextFields(kctx *kong.Context, patch *calendar.Event, forceLocation bool) bool { changed := false if flagProvided(kctx, "summary") { patch.Summary = strings.TrimSpace(c.Summary) @@ -516,7 +584,7 @@ func (c *CalendarUpdateCmd) applyTextFields(kctx *kong.Context, patch *calendar. patch.Description = strings.TrimSpace(c.Description) changed = true } - if flagProvided(kctx, "location") { + if flagProvided(kctx, "location") || forceLocation { patch.Location = strings.TrimSpace(c.Location) changed = true } @@ -648,11 +716,12 @@ func (c *CalendarUpdateCmd) applyGuestOptions(kctx *kong.Context, patch *calenda return changed } -func (c *CalendarUpdateCmd) applyExtendedProperties(kctx *kong.Context, patch *calendar.Event) bool { - if !flagProvided(kctx, "private-prop") && !flagProvided(kctx, "shared-prop") { +func (c *CalendarUpdateCmd) applyExtendedProperties(kctx *kong.Context, patch *calendar.Event, placeProps map[string]string) bool { + hasProps := flagProvided(kctx, "private-prop") || flagProvided(kctx, "shared-prop") || len(placeProps) > 0 + if !hasProps { return false } - patch.ExtendedProperties = buildExtendedProperties(c.PrivateProps, c.SharedProps) + patch.ExtendedProperties = mergeExtendedProperties(buildExtendedProperties(c.PrivateProps, c.SharedProps), placeProps, nil) return true } diff --git a/internal/cmd/calendar_edit_patch_test.go b/internal/cmd/calendar_edit_patch_test.go index d850133a..3d25a4ff 100644 --- a/internal/cmd/calendar_edit_patch_test.go +++ b/internal/cmd/calendar_edit_patch_test.go @@ -37,7 +37,7 @@ func TestCalendarUpdateBuildPatch(t *testing.T) { t.Fatalf("parse: %v", err) } - patch, changed, err := cmd.buildUpdatePatch(kctx) + patch, changed, err := cmd.buildUpdatePatch(kctx, nil, false) if err != nil { t.Fatalf("buildUpdatePatch: %v", err) } @@ -71,7 +71,7 @@ func TestCalendarUpdateBuildPatch_ClearFields(t *testing.T) { t.Fatalf("parse: %v", err) } - patch, changed, err := cmd.buildUpdatePatch(kctx) + patch, changed, err := cmd.buildUpdatePatch(kctx, nil, false) if err != nil { t.Fatalf("buildUpdatePatch: %v", err) } diff --git a/internal/cmd/calendar_edit_test.go b/internal/cmd/calendar_edit_test.go index ce005320..89ff0198 100644 --- a/internal/cmd/calendar_edit_test.go +++ b/internal/cmd/calendar_edit_test.go @@ -44,7 +44,7 @@ func TestCalendarUpdatePatchClearsRecurrence(t *testing.T) { cmd := &CalendarUpdateCmd{} kctx := parseKongContext(t, cmd, []string{"cal1", "evt1", "--rrule", " "}) - patch, _, err := cmd.buildUpdatePatch(kctx) + patch, _, err := cmd.buildUpdatePatch(kctx, nil, false) if err != nil { t.Fatalf("buildUpdatePatch: %v", err) } @@ -63,7 +63,7 @@ func TestCalendarUpdatePatchClearsReminders(t *testing.T) { cmd := &CalendarUpdateCmd{} kctx := parseKongContext(t, cmd, []string{"cal1", "evt1", "--reminder", " "}) - patch, _, err := cmd.buildUpdatePatch(kctx) + patch, _, err := cmd.buildUpdatePatch(kctx, nil, false) if err != nil { t.Fatalf("buildUpdatePatch: %v", err) } diff --git a/internal/cmd/calendar_focus_time.go b/internal/cmd/calendar_focus_time.go index 24a107ea..67049a91 100644 --- a/internal/cmd/calendar_focus_time.go +++ b/internal/cmd/calendar_focus_time.go @@ -77,7 +77,7 @@ func validateAutoDeclineMode(s string) (string, error) { switch s { case "", "none": return "declineNone", nil - case "all": + case scopeAll: return "declineAllConflictingInvitations", nil case "new": return "declineOnlyNewConflictingInvitations", nil diff --git a/internal/cmd/calendar_places.go b/internal/cmd/calendar_places.go new file mode 100644 index 00000000..c605a283 --- /dev/null +++ b/internal/cmd/calendar_places.go @@ -0,0 +1,163 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/steipete/goplaces" + + placescfg "github.com/steipete/gogcli/internal/places" +) + +const placeIDProp = "gog.place_id" + +type placeLookup struct { + SearchText string + PlaceID string + Language string + Region string +} + +type calendarPlace struct { + PlaceID string + Name string + Address string +} + +func validatePlaceLocationFlags(locationSet, locationSearchProvided bool, locationSearch string, placeIDProvided bool, placeID string) error { + searchText := strings.TrimSpace(locationSearch) + rawPlaceID := strings.TrimSpace(placeID) + + if locationSearchProvided && searchText == "" { + return usage("empty --location-search") + } + if placeIDProvided && rawPlaceID == "" { + return usage("empty --place-id") + } + if searchText != "" && rawPlaceID != "" { + return usage("use either --location-search or --place-id (not both)") + } + if locationSet && (searchText != "" || rawPlaceID != "") { + return usage("cannot combine --location with --location-search or --place-id") + } + return nil +} + +func resolveCalendarPlace(ctx context.Context, lookup placeLookup) (*calendarPlace, error) { + searchText := strings.TrimSpace(lookup.SearchText) + placeID := strings.TrimSpace(lookup.PlaceID) + if searchText == "" && placeID == "" { + return nil, nil //nolint:nilnil // intentional: no lookup needed + } + + client, err := newPlacesClientFromConfig() + if err != nil { + return nil, err + } + + language := strings.TrimSpace(lookup.Language) + region := strings.TrimSpace(lookup.Region) + + if searchText != "" { + resp, resolveErr := client.Resolve(ctx, goplaces.LocationResolveRequest{ + LocationText: searchText, + Limit: 1, + Language: language, + Region: region, + }) + if resolveErr != nil { + return nil, resolveErr + } + if len(resp.Results) == 0 { + return nil, fmt.Errorf("no places matched %q", searchText) + } + match := resp.Results[0] + if strings.TrimSpace(match.PlaceID) == "" { + return nil, fmt.Errorf("places lookup returned empty place id for %q", searchText) + } + return &calendarPlace{ + PlaceID: match.PlaceID, + Name: match.Name, + Address: match.Address, + }, nil + } + + details, err := client.DetailsWithOptions(ctx, goplaces.DetailsRequest{ + PlaceID: placeID, + Language: language, + Region: region, + }) + if err != nil { + return nil, err + } + if strings.TrimSpace(details.PlaceID) == "" { + details.PlaceID = placeID + } + return &calendarPlace{ + PlaceID: details.PlaceID, + Name: details.Name, + Address: details.Address, + }, nil +} + +func newPlacesClientFromConfig() (placesClient, error) { + state, err := placescfg.LoadAPIKey() + if err != nil { + return nil, err + } + if strings.TrimSpace(state.Key) == "" { + return nil, usage("Places API key required for --location-search or --place-id. Set GOOGLE_PLACES_API_KEY, configure it in 'gog auth manage', or run 'gog config set places_api_key '") + } + opts := goplaces.Options{ + APIKey: state.Key, + BaseURL: strings.TrimSpace(os.Getenv("GOOGLE_PLACES_BASE_URL")), + RoutesBaseURL: strings.TrimSpace(os.Getenv("GOOGLE_ROUTES_BASE_URL")), + Timeout: 10 * time.Second, + } + return newPlacesClient(opts), nil +} + +func formatPlaceLocation(place *calendarPlace) (string, error) { + if place == nil { + return "", nil + } + name := strings.TrimSpace(place.Name) + address := strings.TrimSpace(place.Address) + switch { + case name != "" && address != "": + return fmt.Sprintf("%s, %s", name, address), nil + case name != "": + return name, nil + case address != "": + return address, nil + default: + return "", fmt.Errorf("place has no name or address") + } +} + +func placePrivateProps(place *calendarPlace) map[string]string { + if place == nil { + return nil + } + placeID := strings.TrimSpace(place.PlaceID) + if placeID == "" { + return nil + } + return map[string]string{placeIDProp: placeID} +} + +func placeLabel(place *calendarPlace) string { + if place == nil { + return "" + } + if strings.TrimSpace(place.Name) != "" { + return strings.TrimSpace(place.Name) + } + if strings.TrimSpace(place.Address) != "" { + return strings.TrimSpace(place.Address) + } + return strings.TrimSpace(place.PlaceID) +} diff --git a/internal/cmd/calendar_places_test.go b/internal/cmd/calendar_places_test.go new file mode 100644 index 00000000..388ea4ec --- /dev/null +++ b/internal/cmd/calendar_places_test.go @@ -0,0 +1,197 @@ +package cmd + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/steipete/goplaces" + "google.golang.org/api/calendar/v3" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +type stubPlacesClient struct { + resolve func(ctx context.Context, req goplaces.LocationResolveRequest) (goplaces.LocationResolveResponse, error) + details func(ctx context.Context, req goplaces.DetailsRequest) (goplaces.PlaceDetails, error) +} + +func (s *stubPlacesClient) Resolve(ctx context.Context, req goplaces.LocationResolveRequest) (goplaces.LocationResolveResponse, error) { + return s.resolve(ctx, req) +} + +func (s *stubPlacesClient) DetailsWithOptions(ctx context.Context, req goplaces.DetailsRequest) (goplaces.PlaceDetails, error) { + return s.details(ctx, req) +} + +func TestCalendarCreateCmd_LocationSearch(t *testing.T) { + origNew := newCalendarService + origPlaces := newPlacesClient + t.Cleanup(func() { + newCalendarService = origNew + newPlacesClient = origPlaces + }) + t.Setenv("GOOGLE_PLACES_API_KEY", "test-key") + + stub := &stubPlacesClient{ + resolve: func(ctx context.Context, req goplaces.LocationResolveRequest) (goplaces.LocationResolveResponse, error) { + return goplaces.LocationResolveResponse{ + Results: []goplaces.ResolvedLocation{ + { + PlaceID: "place-123", + Name: "Elysian Coffee", + Address: "Vancouver, BC", + }, + }, + }, nil + }, + details: func(ctx context.Context, req goplaces.DetailsRequest) (goplaces.PlaceDetails, error) { + return goplaces.PlaceDetails{}, nil + }, + } + newPlacesClient = func(opts goplaces.Options) placesClient { + return stub + } + + var gotEvent calendar.Event + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/calendar/v3") + if r.Method == http.MethodPost && path == "/calendars/cal/events" { + _ = json.NewDecoder(r.Body).Decode(&gotEvent) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "ev1"}) + return + } + http.NotFound(w, r) + })) + defer srv.Close() + + svc, err := calendar.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil } + + 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}) + + cmd := &CalendarCreateCmd{} + if err := runKong(t, cmd, []string{ + "cal", + "--summary", "Coffee", + "--from", "2025-01-02T10:00:00Z", + "--to", "2025-01-02T11:00:00Z", + "--location-search", "Elysian Coffee Vancouver", + }, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("runKong: %v", err) + } + + if gotEvent.Location != "Elysian Coffee, Vancouver, BC" { + t.Fatalf("unexpected location: %q", gotEvent.Location) + } + if gotEvent.ExtendedProperties == nil || gotEvent.ExtendedProperties.Private[placeIDProp] != "place-123" { + t.Fatalf("expected place id in extended properties, got %#v", gotEvent.ExtendedProperties) + } +} + +func TestCalendarUpdateCmd_PlaceID(t *testing.T) { + origNew := newCalendarService + origPlaces := newPlacesClient + t.Cleanup(func() { + newCalendarService = origNew + newPlacesClient = origPlaces + }) + t.Setenv("GOOGLE_PLACES_API_KEY", "test-key") + + stub := &stubPlacesClient{ + resolve: func(ctx context.Context, req goplaces.LocationResolveRequest) (goplaces.LocationResolveResponse, error) { + return goplaces.LocationResolveResponse{}, nil + }, + details: func(ctx context.Context, req goplaces.DetailsRequest) (goplaces.PlaceDetails, error) { + return goplaces.PlaceDetails{ + PlaceID: "place-999", + Name: "Elysian Coffee", + Address: "Vancouver, BC", + }, nil + }, + } + newPlacesClient = func(opts goplaces.Options) placesClient { + return stub + } + + var gotPatch calendar.Event + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/calendar/v3") + switch { + case r.Method == http.MethodGet && path == "/calendars/cal/events/ev": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "ev", + "extendedProperties": map[string]any{ + "private": map[string]any{ + "keep": "yes", + }, + }, + }) + return + case r.Method == http.MethodPatch && path == "/calendars/cal/events/ev": + _ = json.NewDecoder(r.Body).Decode(&gotPatch) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"id": "ev"}) + return + default: + http.NotFound(w, r) + return + } + })) + defer srv.Close() + + svc, err := calendar.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil } + + 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}) + + cmd := &CalendarUpdateCmd{} + if err := runKong(t, cmd, []string{ + "cal", + "ev", + "--place-id", "place-999", + "--scope", "all", + }, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("runKong: %v", err) + } + + if gotPatch.Location != "Elysian Coffee, Vancouver, BC" { + t.Fatalf("unexpected patch location: %q", gotPatch.Location) + } + if gotPatch.ExtendedProperties == nil || gotPatch.ExtendedProperties.Private[placeIDProp] != "place-999" { + t.Fatalf("expected place id in patch extended properties, got %#v", gotPatch.ExtendedProperties) + } + if gotPatch.ExtendedProperties.Private["keep"] != "yes" { + t.Fatalf("expected existing private prop preserved, got %#v", gotPatch.ExtendedProperties.Private) + } +} diff --git a/internal/cmd/config_cmd.go b/internal/cmd/config_cmd.go index 7fe9f6b3..f4461d18 100644 --- a/internal/cmd/config_cmd.go +++ b/internal/cmd/config_cmd.go @@ -19,7 +19,7 @@ type ConfigCmd struct { } type ConfigGetCmd struct { - Key string `arg:"" help:"Config key to get (timezone)"` + Key string `arg:"" help:"Config key to get (timezone, keyring_backend, places_api_key)"` } func (c *ConfigGetCmd) Run(ctx context.Context) error { @@ -59,7 +59,7 @@ func (c *ConfigKeysCmd) Run(ctx context.Context) error { } type ConfigSetCmd struct { - Key string `arg:"" help:"Config key to set (timezone)"` + Key string `arg:"" help:"Config key to set (timezone, keyring_backend, places_api_key)"` Value string `arg:"" help:"Value to set"` } @@ -92,7 +92,7 @@ func (c *ConfigSetCmd) Run(ctx context.Context) error { } type ConfigUnsetCmd struct { - Key string `arg:"" help:"Config key to unset (timezone)"` + Key string `arg:"" help:"Config key to unset (timezone, keyring_backend, places_api_key)"` } func (c *ConfigUnsetCmd) Run(ctx context.Context) error { diff --git a/internal/cmd/config_cmd_test.go b/internal/cmd/config_cmd_test.go index d99f29a0..fc975ad5 100644 --- a/internal/cmd/config_cmd_test.go +++ b/internal/cmd/config_cmd_test.go @@ -15,6 +15,7 @@ func TestConfigCmd_JSONParity(t *testing.T) { cfg := config.File{ KeyringBackend: "file", DefaultTimezone: "UTC", + PlacesAPIKey: "places-123", } if err := config.WriteConfig(cfg); err != nil { t.Fatalf("write config: %v", err) @@ -31,6 +32,7 @@ func TestConfigCmd_JSONParity(t *testing.T) { var list struct { Timezone string `json:"timezone"` KeyringBackend string `json:"keyring_backend"` + PlacesAPIKey string `json:"places_api_key"` } if err := json.Unmarshal([]byte(listOut), &list); err != nil { t.Fatalf("list json parse: %v\nout=%q", err, listOut) @@ -74,6 +76,7 @@ func TestConfigCmd_JSONEmptyValues(t *testing.T) { var list struct { Timezone string `json:"timezone"` KeyringBackend string `json:"keyring_backend"` + PlacesAPIKey string `json:"places_api_key"` } if err := json.Unmarshal([]byte(listOut), &list); err != nil { t.Fatalf("list json parse: %v\nout=%q", err, listOut) @@ -84,6 +87,9 @@ func TestConfigCmd_JSONEmptyValues(t *testing.T) { if list.KeyringBackend != "" { t.Fatalf("expected empty keyring_backend, got %q", list.KeyringBackend) } + if list.PlacesAPIKey != "" { + t.Fatalf("expected empty places_api_key, got %q", list.PlacesAPIKey) + } getOut := captureStdout(t, func() { _ = captureStderr(t, func() { diff --git a/internal/cmd/places_base.go b/internal/cmd/places_base.go new file mode 100644 index 00000000..cc326481 --- /dev/null +++ b/internal/cmd/places_base.go @@ -0,0 +1,16 @@ +package cmd + +import ( + "context" + + "github.com/steipete/goplaces" +) + +type placesClient interface { + Resolve(ctx context.Context, req goplaces.LocationResolveRequest) (goplaces.LocationResolveResponse, error) + DetailsWithOptions(ctx context.Context, req goplaces.DetailsRequest) (goplaces.PlaceDetails, error) +} + +var newPlacesClient = func(opts goplaces.Options) placesClient { + return goplaces.NewClient(opts) +} diff --git a/internal/config/config.go b/internal/config/config.go index 50ab6abe..fa77dd3e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,7 @@ import ( type File struct { KeyringBackend string `json:"keyring_backend,omitempty"` DefaultTimezone string `json:"default_timezone,omitempty"` + PlacesAPIKey string `json:"places_api_key,omitempty"` AccountAliases map[string]string `json:"account_aliases,omitempty"` AccountClients map[string]string `json:"account_clients,omitempty"` ClientDomains map[string]string `json:"client_domains,omitempty"` diff --git a/internal/config/keys.go b/internal/config/keys.go index beb9115e..cda29895 100644 --- a/internal/config/keys.go +++ b/internal/config/keys.go @@ -12,6 +12,7 @@ type Key string const ( KeyTimezone Key = "timezone" KeyKeyringBackend Key = "keyring_backend" + KeyPlacesAPIKey Key = "places_api_key" ) type KeySpec struct { @@ -25,6 +26,7 @@ type KeySpec struct { var keyOrder = []Key{ KeyTimezone, KeyKeyringBackend, + KeyPlacesAPIKey, } var keySpecs = map[Key]KeySpec{ @@ -63,6 +65,22 @@ var keySpecs = map[Key]KeySpec{ return "(not set, using auto)" }, }, + KeyPlacesAPIKey: { + Key: KeyPlacesAPIKey, + Get: func(cfg File) string { + return cfg.PlacesAPIKey + }, + Set: func(cfg *File, value string) error { + cfg.PlacesAPIKey = strings.TrimSpace(value) + return nil + }, + Unset: func(cfg *File) { + cfg.PlacesAPIKey = "" + }, + EmptyHint: func() string { + return "(not set, prefer keychain)" + }, + }, } var ( diff --git a/internal/googleauth/accounts_server.go b/internal/googleauth/accounts_server.go index dbb5b174..b85fd2fe 100644 --- a/internal/googleauth/accounts_server.go +++ b/internal/googleauth/accounts_server.go @@ -20,6 +20,7 @@ import ( "golang.org/x/oauth2" "github.com/steipete/gogcli/internal/config" + placescfg "github.com/steipete/gogcli/internal/places" "github.com/steipete/gogcli/internal/secrets" ) @@ -122,6 +123,8 @@ func StartManageServer(ctx context.Context, opts ManageServerOptions) error { mux.HandleFunc("/oauth2/callback", ms.handleOAuthCallback) mux.HandleFunc("/set-default", ms.handleSetDefault) mux.HandleFunc("/remove-account", ms.handleRemoveAccount) + mux.HandleFunc("/places-key", ms.handlePlacesKey) + mux.HandleFunc("/places-key/clear", ms.handlePlacesKeyClear) ms.server = &http.Server{ Handler: mux, @@ -495,6 +498,156 @@ func (ms *ManageServer) handleRemoveAccount(w http.ResponseWriter, r *http.Reque writeJSON(w, map[string]any{"success": true}) } +func (ms *ManageServer) handlePlacesKey(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + state, err := placescfg.LoadAPIKey() + if err != nil { + writeJSONError(w, "Failed to load Places API key", http.StatusInternalServerError) + return + } + key := strings.TrimSpace(state.Key) + writeJSON(w, map[string]any{ + "configured": key != "", + "source": state.Source, + "hint": placescfg.MaskAPIKey(key), + }) + + return + case http.MethodPost: + if r.Header.Get("X-CSRF-Token") != ms.csrfToken { + writeJSONError(w, "Invalid CSRF token", http.StatusForbidden) + return + } + + var req struct { + APIKey string `json:"apiKey"` + Store string `json:"store"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, "Invalid request", http.StatusBadRequest) + return + } + + apiKey := strings.TrimSpace(req.APIKey) + if apiKey == "" { + writeJSONError(w, "Empty API key", http.StatusBadRequest) + return + } + + store := strings.ToLower(strings.TrimSpace(req.Store)) + if store == "" { + store = "keychain" + } + + switch store { + case "keychain": + if ok, err := shouldEnsureKeychainAccess(); err != nil { + writeJSONError(w, "Failed to resolve keyring backend", http.StatusInternalServerError) + return + } else if ok { + if err := ensureKeychainAccess(); err != nil { + writeJSONError(w, "Failed to unlock keychain", http.StatusInternalServerError) + return + } + } + + if err := placescfg.SaveAPIKeyKeychain(apiKey); err != nil { + writeJSONError(w, "Failed to store Places API key", http.StatusInternalServerError) + return + } + case "config": + if err := placescfg.SaveAPIKeyConfig(apiKey); err != nil { + writeJSONError(w, "Failed to store Places API key", http.StatusInternalServerError) + return + } + default: + writeJSONError(w, "Invalid store", http.StatusBadRequest) + return + } + + writeJSON(w, map[string]any{"success": true}) + + return + default: + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } +} + +func (ms *ManageServer) handlePlacesKeyClear(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + if r.Header.Get("X-CSRF-Token") != ms.csrfToken { + writeJSONError(w, "Invalid CSRF token", http.StatusForbidden) + return + } + + var req struct { + Store string `json:"store"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, "Invalid request", http.StatusBadRequest) + return + } + + store := strings.ToLower(strings.TrimSpace(req.Store)) + if store == "" { + store = "all" + } + + switch store { + case "keychain": + if ok, err := shouldEnsureKeychainAccess(); err != nil { + writeJSONError(w, "Failed to resolve keyring backend", http.StatusInternalServerError) + return + } else if ok { + if err := ensureKeychainAccess(); err != nil { + writeJSONError(w, "Failed to unlock keychain", http.StatusInternalServerError) + return + } + } + + if err := placescfg.ClearAPIKeyKeychain(); err != nil { + writeJSONError(w, "Failed to clear Places API key", http.StatusInternalServerError) + return + } + case "config": + if err := placescfg.ClearAPIKeyConfig(); err != nil { + writeJSONError(w, "Failed to clear Places API key", http.StatusInternalServerError) + return + } + case "all": + if ok, err := shouldEnsureKeychainAccess(); err != nil { + writeJSONError(w, "Failed to resolve keyring backend", http.StatusInternalServerError) + return + } else if ok { + if err := ensureKeychainAccess(); err != nil { + writeJSONError(w, "Failed to unlock keychain", http.StatusInternalServerError) + return + } + } + + if err := placescfg.ClearAPIKeyKeychain(); err != nil { + writeJSONError(w, "Failed to clear Places API key", http.StatusInternalServerError) + return + } + + if err := placescfg.ClearAPIKeyConfig(); err != nil { + writeJSONError(w, "Failed to clear Places API key", http.StatusInternalServerError) + return + } + default: + writeJSONError(w, "Invalid store", http.StatusBadRequest) + return + } + + writeJSON(w, map[string]any{"success": true}) +} + func fetchUserEmailDefault(ctx context.Context, tok *oauth2.Token) (string, error) { if tok == nil { return "", errMissingToken diff --git a/internal/googleauth/templates/accounts.html b/internal/googleauth/templates/accounts.html index 405713c2..1de56375 100644 --- a/internal/googleauth/templates/accounts.html +++ b/internal/googleauth/templates/accounts.html @@ -152,6 +152,105 @@ margin-bottom: 24px; } + .places-section { + margin-bottom: 24px; + } + + .places-card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + } + + .places-status { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: var(--text-muted); + } + + .status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--g-green); + box-shadow: 0 0 0 4px rgba(52, 168, 83, 0.12); + } + + .status-dot.off { + background: var(--g-red); + box-shadow: 0 0 0 4px rgba(234, 67, 53, 0.12); + } + + .places-input { + width: 100%; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--bg-elevated); + color: var(--text); + font-size: 13px; + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; + } + + .places-input:focus { + outline: none; + border-color: var(--border-active); + box-shadow: 0 0 0 3px rgba(66, 133, 244, 0.18); + } + + .places-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .places-btn { + padding: 8px 12px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--bg-elevated); + color: var(--text); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + } + + .places-btn:hover { + border-color: var(--border-active); + background: var(--bg-hover); + } + + .places-btn.primary { + border-color: rgba(66, 133, 244, 0.4); + background: linear-gradient(135deg, rgba(66, 133, 244, 0.18), rgba(66, 133, 244, 0.06)); + } + + .places-btn.primary:hover { + border-color: var(--g-blue); + } + + .places-btn.danger { + border-color: rgba(234, 67, 53, 0.5); + background: rgba(234, 67, 53, 0.08); + color: #fecaca; + } + + .places-btn.danger:hover { + border-color: var(--g-red); + } + + .places-hint { + font-size: 12px; + color: var(--text-dim); + } + .section-header { display: flex; align-items: center; @@ -750,6 +849,26 @@

No accounts connected

+
+
+ + +
+
+
+ + Loading Places API key status... +
+ +
+ + + +
+

Required for calendar place lookup. Config storage is plain text; environment variables override stored keys.

+
+
+
@@ -778,6 +897,7 @@

Use from terminal

` diff --git a/internal/places/apikey.go b/internal/places/apikey.go new file mode 100644 index 00000000..523fc800 --- /dev/null +++ b/internal/places/apikey.go @@ -0,0 +1,133 @@ +package places + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/99designs/keyring" + + "github.com/steipete/gogcli/internal/config" + "github.com/steipete/gogcli/internal/secrets" +) + +const ( + EnvAPIKey = "GOOGLE_PLACES_API_KEY" + secretKeyID = "places/api_key" +) + +var errEmptyPlacesAPIKey = errors.New("empty places api key") + +type APIKeySource string + +const ( + APIKeySourceNone APIKeySource = "none" + APIKeySourceEnv APIKeySource = "env" + APIKeySourceKeychain APIKeySource = "keychain" + APIKeySourceConfig APIKeySource = "config" +) + +type APIKeyState struct { + Key string + Source APIKeySource +} + +func LoadAPIKey() (APIKeyState, error) { + if key := strings.TrimSpace(os.Getenv(EnvAPIKey)); key != "" { + return APIKeyState{Key: key, Source: APIKeySourceEnv}, nil + } + + val, err := secrets.GetSecret(secretKeyID) + if err == nil { + return APIKeyState{Key: string(val), Source: APIKeySourceKeychain}, nil + } + + if !errors.Is(err, keyring.ErrKeyNotFound) { + return APIKeyState{}, fmt.Errorf("read places api key: %w", err) + } + + cfg, err := config.ReadConfig() + if err != nil { + return APIKeyState{}, fmt.Errorf("read places api key config: %w", err) + } + + if key := strings.TrimSpace(cfg.PlacesAPIKey); key != "" { + return APIKeyState{Key: key, Source: APIKeySourceConfig}, nil + } + + return APIKeyState{Source: APIKeySourceNone}, nil +} + +func SaveAPIKeyKeychain(key string) error { + key = strings.TrimSpace(key) + if key == "" { + return errEmptyPlacesAPIKey + } + + if err := secrets.SetSecret(secretKeyID, []byte(key)); err != nil { + return fmt.Errorf("store places api key: %w", err) + } + + return nil +} + +func SaveAPIKeyConfig(key string) error { + key = strings.TrimSpace(key) + if key == "" { + return errEmptyPlacesAPIKey + } + + cfg, err := config.ReadConfig() + if err != nil { + return fmt.Errorf("read config: %w", err) + } + + cfg.PlacesAPIKey = key + + if err := config.WriteConfig(cfg); err != nil { + return fmt.Errorf("write config: %w", err) + } + + return nil +} + +func ClearAPIKeyKeychain() error { + if err := secrets.DeleteSecret(secretKeyID); err != nil { + return fmt.Errorf("delete places api key: %w", err) + } + + return nil +} + +func ClearAPIKeyConfig() error { + cfg, err := config.ReadConfig() + if err != nil { + return fmt.Errorf("read config: %w", err) + } + + if cfg.PlacesAPIKey == "" { + return nil + } + + cfg.PlacesAPIKey = "" + + if err := config.WriteConfig(cfg); err != nil { + return fmt.Errorf("write config: %w", err) + } + + return nil +} + +func MaskAPIKey(key string) string { + key = strings.TrimSpace(key) + if key == "" { + return "" + } + + if len(key) <= 4 { + return "****" + } + + return "****" + key[len(key)-4:] +} diff --git a/internal/places/apikey_test.go b/internal/places/apikey_test.go new file mode 100644 index 00000000..2ce31c8d --- /dev/null +++ b/internal/places/apikey_test.go @@ -0,0 +1,99 @@ +package places + +import ( + "testing" +) + +func TestMaskAPIKey(t *testing.T) { + tests := []struct { + name string + key string + want string + }{ + {name: "empty", key: "", want: ""}, + {name: "whitespace only", key: " ", want: ""}, + {name: "short key 1 char", key: "a", want: "****"}, + {name: "short key 4 chars", key: "abcd", want: "****"}, + {name: "5 chars shows last 4", key: "abcde", want: "****bcde"}, + {name: "typical api key", key: "FAKE-TEST-KEY-abcdefghijklmnopqrstuvwxyz", want: "****wxyz"}, + {name: "key with leading whitespace", key: " abcdefgh", want: "****efgh"}, + {name: "key with trailing whitespace", key: "abcdefgh ", want: "****efgh"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MaskAPIKey(tt.key) + if got != tt.want { + t.Errorf("MaskAPIKey(%q) = %q, want %q", tt.key, got, tt.want) + } + }) + } +} + +func TestSaveAPIKeyKeychain_EmptyKey(t *testing.T) { + tests := []struct { + name string + key string + }{ + {name: "empty", key: ""}, + {name: "whitespace only", key: " "}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := SaveAPIKeyKeychain(tt.key) + if err == nil { + t.Error("expected error for empty key") + } + + if err.Error() != "empty places api key" { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestSaveAPIKeyConfig_EmptyKey(t *testing.T) { + tests := []struct { + name string + key string + }{ + {name: "empty", key: ""}, + {name: "whitespace only", key: " "}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := SaveAPIKeyConfig(tt.key) + if err == nil { + t.Error("expected error for empty key") + } + + if err.Error() != "empty places api key" { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestAPIKeySource_Constants(t *testing.T) { + if APIKeySourceNone != "none" { + t.Errorf("APIKeySourceNone = %q, want %q", APIKeySourceNone, "none") + } + + if APIKeySourceEnv != "env" { + t.Errorf("APIKeySourceEnv = %q, want %q", APIKeySourceEnv, "env") + } + + if APIKeySourceKeychain != "keychain" { + t.Errorf("APIKeySourceKeychain = %q, want %q", APIKeySourceKeychain, "keychain") + } + + if APIKeySourceConfig != "config" { + t.Errorf("APIKeySourceConfig = %q, want %q", APIKeySourceConfig, "config") + } +} + +func TestEnvAPIKey_Constant(t *testing.T) { + if EnvAPIKey != "GOOGLE_PLACES_API_KEY" { + t.Errorf("EnvAPIKey = %q, want %q", EnvAPIKey, "GOOGLE_PLACES_API_KEY") + } +} diff --git a/internal/secrets/store.go b/internal/secrets/store.go index 9976681d..031fc1bf 100644 --- a/internal/secrets/store.go +++ b/internal/secrets/store.go @@ -285,6 +285,28 @@ func GetSecret(key string) ([]byte, error) { return item.Data, nil } +func DeleteSecret(key string) error { + key = strings.TrimSpace(key) + if key == "" { + return errMissingSecretKey + } + + ring, err := openKeyringFunc() + if err != nil { + return err + } + + if err := ring.Remove(key); err != nil { + if errors.Is(err, keyring.ErrKeyNotFound) { + return nil + } + + return wrapKeychainError(fmt.Errorf("delete secret: %w", err)) + } + + return nil +} + func (s *KeyringStore) Keys() ([]string, error) { keys, err := s.ring.Keys() if err != nil { diff --git a/internal/secrets/store_more_test.go b/internal/secrets/store_more_test.go index d3f2e099..9b4af469 100644 --- a/internal/secrets/store_more_test.go +++ b/internal/secrets/store_more_test.go @@ -136,6 +136,31 @@ func TestSetSecretMissingKey(t *testing.T) { } } +func TestDeleteSecret(t *testing.T) { + origOpen := openKeyringFunc + + t.Cleanup(func() { openKeyringFunc = origOpen }) + + ring := keyring.NewArrayKeyring(nil) + openKeyringFunc = func() (keyring.Keyring, error) { return ring, nil } + + if err := SetSecret("places/test", []byte("value")); err != nil { + t.Fatalf("SetSecret: %v", err) + } + + if err := DeleteSecret("places/test"); err != nil { + t.Fatalf("DeleteSecret: %v", err) + } + + if _, err := ring.Get("places/test"); !errors.Is(err, keyring.ErrKeyNotFound) { + t.Fatalf("expected key to be deleted, got %v", err) + } + + if err := DeleteSecret("places/test"); err != nil { + t.Fatalf("DeleteSecret idempotent: %v", err) + } +} + func TestOpenDefaultError(t *testing.T) { origOpen := openKeyringFunc