Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/gog-calendar/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ gog --readonly --account user@example.com calendar events --today --json --wrap-
| `acl` | List calendar ACL |
| `alias` | Manage calendar aliases |
| `calendars` | List calendars |
| `changed` | List most recently changed events (including deletions) |
| `colors` | Show calendar colors |
| `conflicts` | Find busy-time overlaps across calendars |
| `create` | Create an event |
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

## 0.31.0 - 2026-06-24

- Calendar: add `calendar changed` for listing recently modified events, including cancellations, across one or more calendars. (#875) — thanks @sorenisanerd.
- 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.
- 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.
- 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.
Expand Down
1 change: 1 addition & 0 deletions docs/commands.generated.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ Generated from `gog schema --json`.
- [`gog calendar (cal) alias set <alias> <calendarId>`](commands/gog-calendar-alias-set.md) - Set a calendar alias
- [`gog calendar (cal) alias unset <alias>`](commands/gog-calendar-alias-unset.md) - Remove a calendar alias
- [`gog calendar (cal) calendars [flags]`](commands/gog-calendar-calendars.md) - List calendars
- [`gog calendar (cal) changed [<calendarId>] [flags]`](commands/gog-calendar-changed.md) - List most recently changed events (including deletions)
- [`gog calendar (cal) colors`](commands/gog-calendar-colors.md) - Show calendar colors
- [`gog calendar (cal) conflicts [flags]`](commands/gog-calendar-conflicts.md) - Find busy-time overlaps across calendars
- [`gog calendar (cal) create (add,new) <calendarId> [flags]`](commands/gog-calendar-create.md) - Create an event
Expand Down
3 changes: 2 additions & 1 deletion docs/commands/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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.

Generated pages: 699.
Generated pages: 700.

## Top-level Commands

Expand Down Expand Up @@ -138,6 +138,7 @@ Generated pages: 699.
- [gog calendar alias set](gog-calendar-alias-set.md) - Set a calendar alias
- [gog calendar alias unset](gog-calendar-alias-unset.md) - Remove a calendar alias
- [gog calendar calendars](gog-calendar-calendars.md) - List calendars
- [gog calendar changed](gog-calendar-changed.md) - List most recently changed events (including deletions)
- [gog calendar colors](gog-calendar-colors.md) - Show calendar colors
- [gog calendar conflicts](gog-calendar-conflicts.md) - Find busy-time overlaps across calendars
- [gog calendar create](gog-calendar-create.md) - Create an event
Expand Down
54 changes: 54 additions & 0 deletions docs/commands/gog-calendar-changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# `gog calendar changed`

> Generated from `gog schema --json`. Do not edit this page by hand; run `make docs-commands`.

List most recently changed events (including deletions)

## Usage

```bash
gog calendar (cal) changed [<calendarId>] [flags]
```

## Parent

- [gog calendar](gog-calendar.md)

## Flags

| Flag | Type | Default | Help |
| --- | --- | --- | --- |
| `--access-token` | `string` | | Use provided access token directly (bypasses stored refresh tokens; token expires in ~1h) |
| `-a`<br>`--account`<br>`--acct` | `string` | | Account email, alias, or auto for authenticated Google API commands |
| `--all` | `bool` | | Fetch from all calendars |
| `--cal` | `[]string` | | Calendar ID or name (can be repeated) |
| `--calendars` | `string` | | Comma-separated calendar IDs, names, or indices from 'calendar calendars' |
| `--client` | `string` | | OAuth client name (selects stored credentials + token bucket) |
| `--color` | `string` | auto | Color output: auto\|always\|never |
| `--disable-commands` | `string` | | Comma-separated list of disabled commands; dot paths allowed |
| `-n`<br>`--dry-run`<br>`--dryrun`<br>`--noop`<br>`--preview` | `bool` | | Do not make changes; print intended actions and exit successfully |
| `--enable-commands` | `string` | | Comma-separated list of enabled command prefixes; dot paths allowed (restricts CLI) |
| `--enable-commands-exact` | `string` | | Comma-separated list of exact enabled commands; dot paths allowed and parent commands do not enable children |
| `--fail-empty`<br>`--non-empty`<br>`--require-results` | `bool` | | Exit with code 3 if no results |
| `-y`<br>`--force`<br>`--assume-yes`<br>`--yes` | `bool` | | Skip confirmations for destructive commands |
| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) |
| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. |
| `--home` | `string` | | Override gogcli config/data/state/cache root (equivalent to GOG_HOME) |
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
| `--location` | `bool` | | Include event LOCATION column in table output |
| `--max`<br>`--limit` | `int64` | 10 | Max results |
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
| `--readonly` | `bool` | false | Block mutating API requests at runtime; auth add also requests read-only OAuth scopes |
| `--results-only` | `bool` | | In JSON mode, emit only the primary result (drops envelope fields like nextPageToken) |
| `--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. |
| `--since` | `string` | | Lower bound for last-modification time (RFC3339, date, or Go duration: 24h, 168h). Default: 720h (30 days). |
| `-v`<br>`--verbose` | `bool` | | Enable verbose logging |
| `--version` | `kong.VersionFlag` | | Print version and exit |
| `--weekday` | `bool` | | Include start/end day-of-week columns |
| `--wrap-untrusted` | `bool` | false | In JSON/raw output, wrap fetched text fields in external untrusted-content markers |

## See Also

- [gog calendar](gog-calendar.md)
- [Command index](README.md)
1 change: 1 addition & 0 deletions docs/commands/gog-calendar.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ gog calendar (cal) <command> [flags]
- [gog calendar acl](gog-calendar-acl.md) - List calendar ACL
- [gog calendar alias](gog-calendar-alias.md) - Manage calendar aliases
- [gog calendar calendars](gog-calendar-calendars.md) - List calendars
- [gog calendar changed](gog-calendar-changed.md) - List most recently changed events (including deletions)
- [gog calendar colors](gog-calendar-colors.md) - Show calendar colors
- [gog calendar conflicts](gog-calendar-conflicts.md) - Find busy-time overlaps across calendars
- [gog calendar create](gog-calendar-create.md) - Create an event
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/calendar.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type CalendarCmd struct {
ProposeTime CalendarProposeTimeCmd `cmd:"" name:"propose-time" help:"Generate URL to propose a new meeting time (browser-only feature)"`
Colors CalendarColorsCmd `cmd:"" name:"colors" help:"Show calendar colors"`
Conflicts CalendarConflictsCmd `cmd:"" name:"conflicts" help:"Find busy-time overlaps across calendars"`
Changed CalendarChangedCmd `cmd:"" name:"changed" help:"List most recently changed events (including deletions)"`
Search CalendarSearchCmd `cmd:"" name:"search" aliases:"find,query" 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)"`
Expand Down
265 changes: 265 additions & 0 deletions internal/cmd/calendar_changed.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
package cmd

import (
"context"
"sort"
"strings"
"time"

"google.golang.org/api/calendar/v3"

"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/timeparse"
"github.com/steipete/gogcli/internal/ui"
)

type CalendarChangedCmd struct {
CalendarID string `arg:"" name:"calendarId" optional:"" help:"Calendar ID (default: primary)"`
Cal []string `name:"cal" help:"Calendar ID or name (can be repeated)"`
Calendars string `name:"calendars" help:"Comma-separated calendar IDs, names, or indices from 'calendar calendars'"`
Since string `name:"since" help:"Lower bound for last-modification time (RFC3339, date, or Go duration: 24h, 168h). Default: 720h (30 days)."`
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"10"`
All bool `name:"all" help:"Fetch from all calendars"`
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"`
Weekday bool `name:"weekday" help:"Include start/end day-of-week columns"`
Location bool `name:"location" help:"Include event LOCATION column in table output"`
}

func (c *CalendarChangedCmd) Run(ctx context.Context, flags *RootFlags) error {
if c.Max <= 0 {
return usage("max must be > 0")
}

since, err := c.resolveSince()
if err != nil {
return err
}

_, svc, err := requireCalendarService(ctx, flags)
if err != nil {
return err
}
store, err := commandConfigStore(ctx)
if err != nil {
return err
}

calendarID := strings.TrimSpace(c.CalendarID)
calInputs := append([]string{}, c.Cal...)
if strings.TrimSpace(c.Calendars) != "" {
calInputs = append(calInputs, splitCSV(c.Calendars)...)
}
if c.All && (calendarID != "" || len(calInputs) > 0) {
return usage("calendarId or --cal/--calendars not allowed with --all flag")
}
if calendarID != "" && len(calInputs) > 0 {
return usage("calendarId not allowed with --cal/--calendars")
}

sinceRFC3339 := since.UTC().Format(time.RFC3339)

switch {
case c.All:
cals, listErr := listCalendarList(ctx, svc)
if listErr != nil {
return listErr
}
ids := make([]string, 0, len(cals))
for _, cal := range cals {
if cal != nil && strings.TrimSpace(cal.Id) != "" {
ids = append(ids, cal.Id)
}
}
return c.listChangedMulti(ctx, svc, ids, sinceRFC3339, calendarTimezoneHints(cals))
case len(calInputs) > 0:
ids, resolveErr := resolveCalendarIDs(ctx, store, svc, calInputs)
if resolveErr != nil {
return resolveErr
}
if len(ids) == 0 {
return usage("no calendars specified")
}
return c.listChangedMulti(ctx, svc, ids, sinceRFC3339, nil)
default:
calendarID, err = resolveCalendarSelector(ctx, store, svc, calendarID, true)
if err != nil {
return err
}
return c.listChangedSingle(ctx, svc, calendarID, sinceRFC3339)
}
}

func (c *CalendarChangedCmd) resolveSince() (time.Time, error) {
if strings.TrimSpace(c.Since) == "" {
return time.Now().Add(-30 * 24 * time.Hour), nil
}
result, err := timeparse.ParseSince(c.Since, time.Now(), time.UTC)
if err != nil {
return time.Time{}, usagef("invalid --since value: %v", err)
}
return result.Time, nil
}

func (c *CalendarChangedCmd) listChangedSingle(ctx context.Context, svc *calendar.Service, calendarID, since string) error {
calendarTimezone, loc := calendarDisplayTimezone(ctx, svc, calendarID, nil)

items, err := fetchChangedEvents(ctx, svc, calendarID, since)
if err != nil {
return err
}

events := make([]*eventWithCalendar, 0, len(items))
for _, item := range items {
redactCalendarEventForOutput(ctx, item)
events = append(events, wrapEventWithCalendar(item, "", calendarTimezone, loc))
}

sortByUpdatedDesc(events)
if int64(len(events)) > c.Max {
events = events[:c.Max]
}

return c.writeOutput(ctx, events, since, false)
}

func (c *CalendarChangedCmd) listChangedMulti(ctx context.Context, svc *calendar.Service, calendarIDs []string, since string, hints map[string]calendarTimezoneHint) error {
u := ui.FromContext(ctx)
all := make([]*eventWithCalendar, 0)
for _, calID := range calendarIDs {
calID = strings.TrimSpace(calID)
if calID == "" {
continue
}
calendarTimezone, loc := calendarDisplayTimezone(ctx, svc, calID, hints)
items, err := fetchChangedEvents(ctx, svc, calID, since)
if err != nil {
u.Err().Linef("calendar %s: %v", calID, err)
continue
}
for _, item := range items {
redactCalendarEventForOutput(ctx, item)
all = append(all, wrapEventWithCalendar(item, calID, calendarTimezone, loc))
}
}

sortByUpdatedDesc(all)
if int64(len(all)) > c.Max {
all = all[:c.Max]
}

return c.writeOutput(ctx, all, since, true)
}

func fetchChangedEvents(ctx context.Context, svc *calendar.Service, calendarID, since string) ([]*calendar.Event, error) {
fetch := func(pageToken string) ([]*calendar.Event, string, error) {
// Calendar always returns entries deleted since updatedMin. Request them
// explicitly too: deletions are changes and belong in this command's output.
call := svc.Events.List(calendarID).
UpdatedMin(since).
ShowDeleted(true).
OrderBy("updated").
MaxResults(250).
Context(ctx)
if pageToken != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, "", err
}
return resp.Items, resp.NextPageToken, nil
}
items, err := collectAllPages("", fetch)
return items, err
}

func sortByUpdatedDesc(events []*eventWithCalendar) {
sort.SliceStable(events, func(i, j int) bool {
a := calendarEvent(events[i]).Updated
b := calendarEvent(events[j]).Updated
return a > b
})
}

func (c *CalendarChangedCmd) writeOutput(ctx context.Context, events []*eventWithCalendar, since string, includeCalendar bool) error {
u := ui.FromContext(ctx)
if outfmt.IsJSON(ctx) {
jsonItems := make([]any, 0, len(events))
for _, e := range events {
jsonItems = append(jsonItems, e)
}
if err := outfmt.WriteJSON(ctx, stdoutWriter(ctx), map[string]any{
"events": jsonItems,
"since": since,
}); err != nil {
return err
}
if len(events) == 0 {
return failEmptyExit(c.FailEmpty)
}
return nil
}

if len(events) == 0 {
u.Err().Println("No events")
return failEmptyExit(c.FailEmpty)
}
return outfmt.WriteTable(ctx, stdoutWriter(ctx), compactCalendarRows(events), changedEventColumns(includeCalendar, c.Weekday, c.Location))
}

func changedEventColumns(includeCalendar, showWeekday, showLocation bool) []outfmt.Column[*eventWithCalendar] {
columns := make([]outfmt.Column[*eventWithCalendar], 0, 8)
columns = append(columns, outfmt.Column[*eventWithCalendar]{
Header: "UPDATED",
Value: func(e *eventWithCalendar) string { return calendarEvent(e).Updated },
})
if includeCalendar {
columns = append(columns, outfmt.Column[*eventWithCalendar]{
Header: "CALENDAR",
Value: func(e *eventWithCalendar) string { return e.CalendarID },
})
}
columns = append(columns,
outfmt.Column[*eventWithCalendar]{
Header: "ID",
Value: func(e *eventWithCalendar) string { return calendarEvent(e).Id },
},
outfmt.Column[*eventWithCalendar]{
Header: "START",
Value: eventDisplayStart,
},
)
if showWeekday {
columns = append(columns, outfmt.Column[*eventWithCalendar]{
Header: "START_DOW",
Value: func(e *eventWithCalendar) string {
startDay, _ := calendarEventWeekdays(e, includeCalendar)
return startDay
},
})
}
columns = append(columns, outfmt.Column[*eventWithCalendar]{
Header: "END",
Value: eventDisplayEnd,
})
if showWeekday {
columns = append(columns, outfmt.Column[*eventWithCalendar]{
Header: "END_DOW",
Value: func(e *eventWithCalendar) string {
_, endDay := calendarEventWeekdays(e, includeCalendar)
return endDay
},
})
}
columns = append(columns, outfmt.Column[*eventWithCalendar]{
Header: "SUMMARY",
Value: func(e *eventWithCalendar) string { return calendarEvent(e).Summary },
})
if showLocation {
columns = append(columns, outfmt.Column[*eventWithCalendar]{
Header: "LOCATION",
Value: eventDisplayLocation,
})
}
return columns
}
Loading
Loading