gogcli/internal/cmd/calendar.go
salmonumbrella 07ffcb5d84
fix(paths): expand ~ in user-provided file paths (#56)
* fix(paths): expand ~ in user-provided file paths

When users specify paths with ~ (e.g., --out ~/Downloads/file.pdf) and
the path is quoted in the shell command, the tilde is not expanded by
the shell. This caused files to be written to a literal ~/Downloads
directory instead of the user's home directory.

Add config.ExpandPath() function that expands ~ at the beginning of
paths to the user's home directory. Apply this fix to all user-provided
file paths across:

- gmail attachment download (--out)
- drive download/export (--out)
- drive upload (localPath argument)
- auth token export (--out)
- auth credentials/import/keep (input paths)
- gmail thread attachments (--out-dir)
- gmail send/drafts (--attach)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(lint): address wrapcheck and wsl issues

* fix(calendar): support ISO 8601 time format and add 'list' alias

- Add parsing for ISO 8601 datetime with numeric timezone without colon
  (e.g., 2026-01-09T16:38:41-0800), which is the format produced by
  macOS `date +%Y-%m-%dT%H:%M:%S%z`
- Add 'list' as an alias for 'events' subcommand for more intuitive CLI
  usage (gog calendar list instead of gog calendar events)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore(changelog): note PR #56

* chore(lint): dedupe file string

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-10 02:19:30 +00:00

223 lines
7.5 KiB
Go

package cmd
import (
"context"
"fmt"
"os"
"strings"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
type CalendarCmd struct {
Calendars CalendarCalendarsCmd `cmd:"" name:"calendars" help:"List calendars"`
ACL CalendarAclCmd `cmd:"" name:"acl" help:"List calendar ACL"`
Events CalendarEventsCmd `cmd:"" name:"events" aliases:"list" help:"List events from a calendar or all calendars"`
Event CalendarEventCmd `cmd:"" name:"event" help:"Get event"`
Create CalendarCreateCmd `cmd:"" name:"create" help:"Create an event"`
Update CalendarUpdateCmd `cmd:"" name:"update" help:"Update an event"`
Delete CalendarDeleteCmd `cmd:"" name:"delete" help:"Delete an event"`
FreeBusy CalendarFreeBusyCmd `cmd:"" name:"freebusy" help:"Get free/busy"`
Respond CalendarRespondCmd `cmd:"" name:"respond" help:"Respond to an event invitation"`
Colors CalendarColorsCmd `cmd:"" name:"colors" help:"Show calendar colors"`
Conflicts CalendarConflictsCmd `cmd:"" name:"conflicts" help:"Find conflicts"`
Search CalendarSearchCmd `cmd:"" name:"search" help:"Search events"`
Time CalendarTimeCmd `cmd:"" name:"time" help:"Show server time"`
Users CalendarUsersCmd `cmd:"" name:"users" help:"List workspace users (use their email as calendar ID)"`
Team CalendarTeamCmd `cmd:"" name:"team" help:"Show events for all members of a Google Group"`
FocusTime CalendarFocusTimeCmd `cmd:"" name:"focus-time" help:"Create a Focus Time block"`
OOO CalendarOOOCmd `cmd:"" name:"out-of-office" aliases:"ooo" help:"Create an Out of Office event"`
WorkingLocation CalendarWorkingLocationCmd `cmd:"" name:"working-location" aliases:"wl" help:"Set working location (home/office/custom)"`
}
type CalendarCalendarsCmd struct {
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"`
Page string `name:"page" help:"Page token"`
}
func (c *CalendarCalendarsCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newCalendarService(ctx, account)
if err != nil {
return err
}
resp, err := svc.CalendarList.List().MaxResults(c.Max).PageToken(c.Page).Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"calendars": resp.Items,
"nextPageToken": resp.NextPageToken,
})
}
if len(resp.Items) == 0 {
u.Err().Println("No calendars")
return nil
}
w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "ID\tNAME\tROLE")
for _, cal := range resp.Items {
fmt.Fprintf(w, "%s\t%s\t%s\n", cal.Id, cal.Summary, cal.AccessRole)
}
printNextPageHint(u, resp.NextPageToken)
return nil
}
type CalendarAclCmd struct {
CalendarID string `arg:"" name:"calendarId" help:"Calendar ID"`
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"`
Page string `name:"page" help:"Page token"`
}
func (c *CalendarAclCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
calendarID := strings.TrimSpace(c.CalendarID)
if calendarID == "" {
return usage("calendarId required")
}
svc, err := newCalendarService(ctx, account)
if err != nil {
return err
}
resp, err := svc.Acl.List(calendarID).MaxResults(c.Max).PageToken(c.Page).Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{
"rules": resp.Items,
"nextPageToken": resp.NextPageToken,
})
}
if len(resp.Items) == 0 {
u.Err().Println("No ACL rules")
return nil
}
w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "SCOPE_TYPE\tSCOPE_VALUE\tROLE")
for _, rule := range resp.Items {
scopeType := ""
scopeValue := ""
if rule.Scope != nil {
scopeType = rule.Scope.Type
scopeValue = rule.Scope.Value
}
fmt.Fprintf(w, "%s\t%s\t%s\n", scopeType, scopeValue, rule.Role)
}
printNextPageHint(u, resp.NextPageToken)
return nil
}
type CalendarEventsCmd struct {
CalendarID string `arg:"" name:"calendarId" optional:"" help:"Calendar ID"`
From string `name:"from" help:"Start time (RFC3339, date, or relative: today, tomorrow, monday)"`
To string `name:"to" help:"End time (RFC3339, date, or relative)"`
Today bool `name:"today" help:"Today only (timezone-aware)"`
Tomorrow bool `name:"tomorrow" help:"Tomorrow only (timezone-aware)"`
Week bool `name:"week" help:"This week (uses --week-start, default Mon)"`
Days int `name:"days" help:"Next N days (timezone-aware)" default:"0"`
WeekStart string `name:"week-start" help:"Week start day for --week (sun, mon, ...)" default:""`
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"10"`
Page string `name:"page" help:"Page token"`
Query string `name:"query" help:"Free text search"`
All bool `name:"all" help:"Fetch events from all calendars"`
PrivatePropFilter string `name:"private-prop-filter" help:"Filter by private extended property (key=value)"`
SharedPropFilter string `name:"shared-prop-filter" help:"Filter by shared extended property (key=value)"`
Fields string `name:"fields" help:"Comma-separated fields to return"`
}
func (c *CalendarEventsCmd) Run(ctx context.Context, flags *RootFlags) error {
account, err := requireAccount(flags)
if err != nil {
return err
}
if !c.All && strings.TrimSpace(c.CalendarID) == "" {
return usage("calendarId required unless --all is specified")
}
if c.All && strings.TrimSpace(c.CalendarID) != "" {
return usage("calendarId not allowed with --all flag")
}
svc, err := newCalendarService(ctx, account)
if err != nil {
return err
}
// Use timezone-aware time resolution
timeRange, err := ResolveTimeRange(ctx, svc, TimeRangeFlags{
From: c.From,
To: c.To,
Today: c.Today,
Tomorrow: c.Tomorrow,
Week: c.Week,
Days: c.Days,
WeekStart: c.WeekStart,
})
if err != nil {
return err
}
from, to := timeRange.FormatRFC3339()
if c.All {
return listAllCalendarsEvents(ctx, svc, from, to, c.Max, c.Page, c.Query, c.PrivatePropFilter, c.SharedPropFilter, c.Fields)
}
calendarID := strings.TrimSpace(c.CalendarID)
return listCalendarEvents(ctx, svc, calendarID, from, to, c.Max, c.Page, c.Query, c.PrivatePropFilter, c.SharedPropFilter, c.Fields)
}
type CalendarEventCmd struct {
CalendarID string `arg:"" name:"calendarId" help:"Calendar ID"`
EventID string `arg:"" name:"eventId" help:"Event ID"`
}
func (c *CalendarEventCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
calendarID := strings.TrimSpace(c.CalendarID)
eventID := strings.TrimSpace(c.EventID)
if calendarID == "" {
return usage("empty calendarId")
}
if eventID == "" {
return usage("empty eventId")
}
svc, err := newCalendarService(ctx, account)
if err != nil {
return err
}
event, err := svc.Events.Get(calendarID, eventID).Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(os.Stdout, map[string]any{"event": event})
}
printCalendarEvent(u, event)
return nil
}