|
| 1 | +package cmd |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "sort" |
| 6 | + "strings" |
| 7 | + "time" |
| 8 | + |
| 9 | + "google.golang.org/api/calendar/v3" |
| 10 | + |
| 11 | + "github.com/steipete/gogcli/internal/outfmt" |
| 12 | + "github.com/steipete/gogcli/internal/timeparse" |
| 13 | + "github.com/steipete/gogcli/internal/ui" |
| 14 | +) |
| 15 | + |
| 16 | +type CalendarChangedCmd struct { |
| 17 | + CalendarID string `arg:"" name:"calendarId" optional:"" help:"Calendar ID (default: primary)"` |
| 18 | + Cal []string `name:"cal" help:"Calendar ID or name (can be repeated)"` |
| 19 | + Calendars string `name:"calendars" help:"Comma-separated calendar IDs, names, or indices from 'calendar calendars'"` |
| 20 | + Since string `name:"since" help:"Lower bound for last-modification time (RFC3339, date, or Go duration: 24h, 168h). Default: 720h (30 days)."` |
| 21 | + Max int64 `name:"max" aliases:"limit" help:"Max results" default:"10"` |
| 22 | + All bool `name:"all" help:"Fetch from all calendars"` |
| 23 | + FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"` |
| 24 | + Weekday bool `name:"weekday" help:"Include start/end day-of-week columns"` |
| 25 | + Location bool `name:"location" help:"Include event LOCATION column in table output"` |
| 26 | +} |
| 27 | + |
| 28 | +func (c *CalendarChangedCmd) Run(ctx context.Context, flags *RootFlags) error { |
| 29 | + if c.Max <= 0 { |
| 30 | + return usage("max must be > 0") |
| 31 | + } |
| 32 | + |
| 33 | + since, err := c.resolveSince() |
| 34 | + if err != nil { |
| 35 | + return err |
| 36 | + } |
| 37 | + |
| 38 | + _, svc, err := requireCalendarService(ctx, flags) |
| 39 | + if err != nil { |
| 40 | + return err |
| 41 | + } |
| 42 | + store, err := commandConfigStore(ctx) |
| 43 | + if err != nil { |
| 44 | + return err |
| 45 | + } |
| 46 | + |
| 47 | + calendarID := strings.TrimSpace(c.CalendarID) |
| 48 | + calInputs := append([]string{}, c.Cal...) |
| 49 | + if strings.TrimSpace(c.Calendars) != "" { |
| 50 | + calInputs = append(calInputs, splitCSV(c.Calendars)...) |
| 51 | + } |
| 52 | + if c.All && (calendarID != "" || len(calInputs) > 0) { |
| 53 | + return usage("calendarId or --cal/--calendars not allowed with --all flag") |
| 54 | + } |
| 55 | + if calendarID != "" && len(calInputs) > 0 { |
| 56 | + return usage("calendarId not allowed with --cal/--calendars") |
| 57 | + } |
| 58 | + |
| 59 | + sinceRFC3339 := since.UTC().Format(time.RFC3339) |
| 60 | + |
| 61 | + switch { |
| 62 | + case c.All: |
| 63 | + cals, listErr := listCalendarList(ctx, svc) |
| 64 | + if listErr != nil { |
| 65 | + return listErr |
| 66 | + } |
| 67 | + ids := make([]string, 0, len(cals)) |
| 68 | + for _, cal := range cals { |
| 69 | + if cal != nil && strings.TrimSpace(cal.Id) != "" { |
| 70 | + ids = append(ids, cal.Id) |
| 71 | + } |
| 72 | + } |
| 73 | + return c.listChangedMulti(ctx, svc, ids, sinceRFC3339, calendarTimezoneHints(cals)) |
| 74 | + case len(calInputs) > 0: |
| 75 | + ids, resolveErr := resolveCalendarIDs(ctx, store, svc, calInputs) |
| 76 | + if resolveErr != nil { |
| 77 | + return resolveErr |
| 78 | + } |
| 79 | + if len(ids) == 0 { |
| 80 | + return usage("no calendars specified") |
| 81 | + } |
| 82 | + return c.listChangedMulti(ctx, svc, ids, sinceRFC3339, nil) |
| 83 | + default: |
| 84 | + calendarID, err = resolveCalendarSelector(ctx, store, svc, calendarID, true) |
| 85 | + if err != nil { |
| 86 | + return err |
| 87 | + } |
| 88 | + return c.listChangedSingle(ctx, svc, calendarID, sinceRFC3339) |
| 89 | + } |
| 90 | +} |
| 91 | + |
| 92 | +func (c *CalendarChangedCmd) resolveSince() (time.Time, error) { |
| 93 | + if strings.TrimSpace(c.Since) == "" { |
| 94 | + return time.Now().Add(-30 * 24 * time.Hour), nil |
| 95 | + } |
| 96 | + result, err := timeparse.ParseSince(c.Since, time.Now(), time.UTC) |
| 97 | + if err != nil { |
| 98 | + return time.Time{}, usagef("invalid --since value: %v", err) |
| 99 | + } |
| 100 | + return result.Time, nil |
| 101 | +} |
| 102 | + |
| 103 | +func (c *CalendarChangedCmd) listChangedSingle(ctx context.Context, svc *calendar.Service, calendarID, since string) error { |
| 104 | + calendarTimezone, loc := calendarDisplayTimezone(ctx, svc, calendarID, nil) |
| 105 | + |
| 106 | + items, err := fetchChangedEvents(ctx, svc, calendarID, since) |
| 107 | + if err != nil { |
| 108 | + return err |
| 109 | + } |
| 110 | + |
| 111 | + events := make([]*eventWithCalendar, 0, len(items)) |
| 112 | + for _, item := range items { |
| 113 | + redactCalendarEventForOutput(ctx, item) |
| 114 | + events = append(events, wrapEventWithCalendar(item, "", calendarTimezone, loc)) |
| 115 | + } |
| 116 | + |
| 117 | + sortByUpdatedDesc(events) |
| 118 | + if int64(len(events)) > c.Max { |
| 119 | + events = events[:c.Max] |
| 120 | + } |
| 121 | + |
| 122 | + return c.writeOutput(ctx, events, since, false) |
| 123 | +} |
| 124 | + |
| 125 | +func (c *CalendarChangedCmd) listChangedMulti(ctx context.Context, svc *calendar.Service, calendarIDs []string, since string, hints map[string]calendarTimezoneHint) error { |
| 126 | + u := ui.FromContext(ctx) |
| 127 | + all := make([]*eventWithCalendar, 0) |
| 128 | + for _, calID := range calendarIDs { |
| 129 | + calID = strings.TrimSpace(calID) |
| 130 | + if calID == "" { |
| 131 | + continue |
| 132 | + } |
| 133 | + calendarTimezone, loc := calendarDisplayTimezone(ctx, svc, calID, hints) |
| 134 | + items, err := fetchChangedEvents(ctx, svc, calID, since) |
| 135 | + if err != nil { |
| 136 | + u.Err().Linef("calendar %s: %v", calID, err) |
| 137 | + continue |
| 138 | + } |
| 139 | + for _, item := range items { |
| 140 | + redactCalendarEventForOutput(ctx, item) |
| 141 | + all = append(all, wrapEventWithCalendar(item, calID, calendarTimezone, loc)) |
| 142 | + } |
| 143 | + } |
| 144 | + |
| 145 | + sortByUpdatedDesc(all) |
| 146 | + if int64(len(all)) > c.Max { |
| 147 | + all = all[:c.Max] |
| 148 | + } |
| 149 | + |
| 150 | + return c.writeOutput(ctx, all, since, true) |
| 151 | +} |
| 152 | + |
| 153 | +func fetchChangedEvents(ctx context.Context, svc *calendar.Service, calendarID, since string) ([]*calendar.Event, error) { |
| 154 | + fetch := func(pageToken string) ([]*calendar.Event, string, error) { |
| 155 | + // Calendar always returns entries deleted since updatedMin. Request them |
| 156 | + // explicitly too: deletions are changes and belong in this command's output. |
| 157 | + call := svc.Events.List(calendarID). |
| 158 | + UpdatedMin(since). |
| 159 | + ShowDeleted(true). |
| 160 | + OrderBy("updated"). |
| 161 | + MaxResults(250). |
| 162 | + Context(ctx) |
| 163 | + if pageToken != "" { |
| 164 | + call = call.PageToken(pageToken) |
| 165 | + } |
| 166 | + resp, err := call.Do() |
| 167 | + if err != nil { |
| 168 | + return nil, "", err |
| 169 | + } |
| 170 | + return resp.Items, resp.NextPageToken, nil |
| 171 | + } |
| 172 | + items, err := collectAllPages("", fetch) |
| 173 | + return items, err |
| 174 | +} |
| 175 | + |
| 176 | +func sortByUpdatedDesc(events []*eventWithCalendar) { |
| 177 | + sort.SliceStable(events, func(i, j int) bool { |
| 178 | + a := calendarEvent(events[i]).Updated |
| 179 | + b := calendarEvent(events[j]).Updated |
| 180 | + return a > b |
| 181 | + }) |
| 182 | +} |
| 183 | + |
| 184 | +func (c *CalendarChangedCmd) writeOutput(ctx context.Context, events []*eventWithCalendar, since string, includeCalendar bool) error { |
| 185 | + u := ui.FromContext(ctx) |
| 186 | + if outfmt.IsJSON(ctx) { |
| 187 | + jsonItems := make([]any, 0, len(events)) |
| 188 | + for _, e := range events { |
| 189 | + jsonItems = append(jsonItems, e) |
| 190 | + } |
| 191 | + if err := outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{ |
| 192 | + "events": jsonItems, |
| 193 | + "since": since, |
| 194 | + }); err != nil { |
| 195 | + return err |
| 196 | + } |
| 197 | + if len(events) == 0 { |
| 198 | + return failEmptyExit(c.FailEmpty) |
| 199 | + } |
| 200 | + return nil |
| 201 | + } |
| 202 | + |
| 203 | + if len(events) == 0 { |
| 204 | + u.Err().Println("No events") |
| 205 | + return failEmptyExit(c.FailEmpty) |
| 206 | + } |
| 207 | + return outfmt.WriteTable(ctx, stdoutWriter(ctx), compactCalendarRows(events), changedEventColumns(includeCalendar, c.Weekday, c.Location)) |
| 208 | +} |
| 209 | + |
| 210 | +func changedEventColumns(includeCalendar, showWeekday, showLocation bool) []outfmt.Column[*eventWithCalendar] { |
| 211 | + columns := make([]outfmt.Column[*eventWithCalendar], 0, 8) |
| 212 | + columns = append(columns, outfmt.Column[*eventWithCalendar]{ |
| 213 | + Header: "UPDATED", |
| 214 | + Value: func(e *eventWithCalendar) string { return calendarEvent(e).Updated }, |
| 215 | + }) |
| 216 | + if includeCalendar { |
| 217 | + columns = append(columns, outfmt.Column[*eventWithCalendar]{ |
| 218 | + Header: "CALENDAR", |
| 219 | + Value: func(e *eventWithCalendar) string { return e.CalendarID }, |
| 220 | + }) |
| 221 | + } |
| 222 | + columns = append(columns, |
| 223 | + outfmt.Column[*eventWithCalendar]{ |
| 224 | + Header: "ID", |
| 225 | + Value: func(e *eventWithCalendar) string { return calendarEvent(e).Id }, |
| 226 | + }, |
| 227 | + outfmt.Column[*eventWithCalendar]{ |
| 228 | + Header: "START", |
| 229 | + Value: eventDisplayStart, |
| 230 | + }, |
| 231 | + ) |
| 232 | + if showWeekday { |
| 233 | + columns = append(columns, outfmt.Column[*eventWithCalendar]{ |
| 234 | + Header: "START_DOW", |
| 235 | + Value: func(e *eventWithCalendar) string { |
| 236 | + startDay, _ := calendarEventWeekdays(e, includeCalendar) |
| 237 | + return startDay |
| 238 | + }, |
| 239 | + }) |
| 240 | + } |
| 241 | + columns = append(columns, outfmt.Column[*eventWithCalendar]{ |
| 242 | + Header: "END", |
| 243 | + Value: eventDisplayEnd, |
| 244 | + }) |
| 245 | + if showWeekday { |
| 246 | + columns = append(columns, outfmt.Column[*eventWithCalendar]{ |
| 247 | + Header: "END_DOW", |
| 248 | + Value: func(e *eventWithCalendar) string { |
| 249 | + _, endDay := calendarEventWeekdays(e, includeCalendar) |
| 250 | + return endDay |
| 251 | + }, |
| 252 | + }) |
| 253 | + } |
| 254 | + columns = append(columns, outfmt.Column[*eventWithCalendar]{ |
| 255 | + Header: "SUMMARY", |
| 256 | + Value: func(e *eventWithCalendar) string { return calendarEvent(e).Summary }, |
| 257 | + }) |
| 258 | + if showLocation { |
| 259 | + columns = append(columns, outfmt.Column[*eventWithCalendar]{ |
| 260 | + Header: "LOCATION", |
| 261 | + Value: eventDisplayLocation, |
| 262 | + }) |
| 263 | + } |
| 264 | + return columns |
| 265 | +} |
0 commit comments