Skip to content

Commit e022bb9

Browse files
authored
feat(calendar): list recently changed events (#875)
Co-authored-by: Søren L. Hansen <sorenisanerd@gmail.com>
1 parent dafe804 commit e022bb9

9 files changed

Lines changed: 545 additions & 1 deletion

File tree

.agents/skills/gog-calendar/SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ gog --readonly --account user@example.com calendar events --today --json --wrap-
3131
| `acl` | List calendar ACL |
3232
| `alias` | Manage calendar aliases |
3333
| `calendars` | List calendars |
34+
| `changed` | List most recently changed events (including deletions) |
3435
| `colors` | Show calendar colors |
3536
| `conflicts` | Find busy-time overlaps across calendars |
3637
| `create` | Create an event |

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
## 0.31.0 - 2026-06-24
66

7+
- Calendar: add `calendar changed` for listing recently modified events, including cancellations, across one or more calendars. (#875) — thanks @sorenisanerd.
78
- Gmail: preserve HTML fragments from `--signature-file` instead of escaping their markup, without broadening HTML detection for message display or reply quoting. (#879) — thanks @kesslerio.
89
- Docs: honor `--tab` when setting document layout so `page-layout --tab` (and `write --pageless --tab`) target the specified tab instead of always the default tab. Page layout is per-tab; previously these silently no-opped on secondary tabs of multi-tab documents. (#878) — thanks @atmasphere.
910
- Auth: recover from corrupt stored OAuth token payloads by routing only classified decode corruption through the normal re-authentication flow while preserving operational keyring errors. (#872, #874) — thanks @KrasimirKralev.

docs/commands.generated.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ Generated from `gog schema --json`.
8686
- [`gog calendar (cal) alias set <alias> <calendarId>`](commands/gog-calendar-alias-set.md) - Set a calendar alias
8787
- [`gog calendar (cal) alias unset <alias>`](commands/gog-calendar-alias-unset.md) - Remove a calendar alias
8888
- [`gog calendar (cal) calendars [flags]`](commands/gog-calendar-calendars.md) - List calendars
89+
- [`gog calendar (cal) changed [<calendarId>] [flags]`](commands/gog-calendar-changed.md) - List most recently changed events (including deletions)
8990
- [`gog calendar (cal) colors`](commands/gog-calendar-colors.md) - Show calendar colors
9091
- [`gog calendar (cal) conflicts [flags]`](commands/gog-calendar-conflicts.md) - Find busy-time overlaps across calendars
9192
- [`gog calendar (cal) create (add,new) <calendarId> [flags]`](commands/gog-calendar-create.md) - Create an event

docs/commands/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Every `gog` command has a generated docs page. The source of truth is the live CLI schema; run `make docs-commands` after changing command names, flags, help text, aliases, or arguments.
44

5-
Generated pages: 699.
5+
Generated pages: 700.
66

77
## Top-level Commands
88

@@ -138,6 +138,7 @@ Generated pages: 699.
138138
- [gog calendar alias set](gog-calendar-alias-set.md) - Set a calendar alias
139139
- [gog calendar alias unset](gog-calendar-alias-unset.md) - Remove a calendar alias
140140
- [gog calendar calendars](gog-calendar-calendars.md) - List calendars
141+
- [gog calendar changed](gog-calendar-changed.md) - List most recently changed events (including deletions)
141142
- [gog calendar colors](gog-calendar-colors.md) - Show calendar colors
142143
- [gog calendar conflicts](gog-calendar-conflicts.md) - Find busy-time overlaps across calendars
143144
- [gog calendar create](gog-calendar-create.md) - Create an event
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# `gog calendar changed`
2+
3+
> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`.
4+
5+
List most recently changed events (including deletions)
6+
7+
## Usage
8+
9+
```bash
10+
gog calendar (cal) changed [<calendarId>] [flags]
11+
```
12+
13+
## Parent
14+
15+
- [gog calendar](gog-calendar.md)
16+
17+
## Flags
18+
19+
| Flag | Type | Default | Help |
20+
| --- | --- | --- | --- |
21+
| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) |
22+
| `-a`<br>`--account`<br>`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands |
23+
| `--all` | `bool` | | Fetch from all calendars |
24+
| `--cal` | `[]string` | | Calendar ID or name (can be repeated) |
25+
| `--calendars` | `string` | | Comma-separated calendar IDs, names, or indices from 'calendar calendars' |
26+
| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) |
27+
| `--color` | `string` | auto | Color output: auto\|always\|never |
28+
| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed |
29+
| `-n`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully |
30+
| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) |
31+
| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children |
32+
| `--fail-empty`<br>`--non-empty`<br>`--require-results` | `bool` | | Exit with code 3 if no results |
33+
| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands |
34+
| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) |
35+
| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. |
36+
| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) |
37+
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
38+
| `--location` | `bool` | | Include event LOCATION column in table output |
39+
| `--max`<br>`--limit` | `int64` | 10 | Max results |
40+
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
41+
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
42+
| `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes |
43+
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
44+
| `--select`<br>`--pick`<br>`--project` | `string` | | In JSON mode, select comma-separated fields (best-effort; supports dot paths). Desire path: use --fields for most commands. |
45+
| `--since` | `string` | | Lower bound for last-modification time (RFC3339, date, or Go duration: 24h, 168h). Default: 720h (30 days). |
46+
| `-v`<br>`--verbose` | `bool` | | Enable verbose logging |
47+
| `--version` | `kong.VersionFlag` | | Print version and exit |
48+
| `--weekday` | `bool` | | Include start/end day-of-week columns |
49+
| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers |
50+
51+
## See Also
52+
53+
- [gog calendar](gog-calendar.md)
54+
- [Command index](README.md)

docs/commands/gog-calendar.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ gog calendar (cal) <command> [flags]
1919
- [gog calendar acl](gog-calendar-acl.md) - List calendar ACL
2020
- [gog calendar alias](gog-calendar-alias.md) - Manage calendar aliases
2121
- [gog calendar calendars](gog-calendar-calendars.md) - List calendars
22+
- [gog calendar changed](gog-calendar-changed.md) - List most recently changed events (including deletions)
2223
- [gog calendar colors](gog-calendar-colors.md) - Show calendar colors
2324
- [gog calendar conflicts](gog-calendar-conflicts.md) - Find busy-time overlaps across calendars
2425
- [gog calendar create](gog-calendar-create.md) - Create an event

internal/cmd/calendar.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type CalendarCmd struct {
2020
ProposeTime CalendarProposeTimeCmd `cmd:"" name:"propose-time" help:"Generate URL to propose a new meeting time (browser-only feature)"`
2121
Colors CalendarColorsCmd `cmd:"" name:"colors" help:"Show calendar colors"`
2222
Conflicts CalendarConflictsCmd `cmd:"" name:"conflicts" help:"Find busy-time overlaps across calendars"`
23+
Changed CalendarChangedCmd `cmd:"" name:"changed" help:"List most recently changed events (including deletions)"`
2324
Search CalendarSearchCmd `cmd:"" name:"search" aliases:"find,query" help:"Search events"`
2425
Time CalendarTimeCmd `cmd:"" name:"time" help:"Show server time"`
2526
Users CalendarUsersCmd `cmd:"" name:"users" help:"List workspace users (use their email as calendar ID)"`

internal/cmd/calendar_changed.go

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
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

Comments
 (0)