diff --git a/README.md b/README.md index ea804547..9405251f 100644 --- a/README.md +++ b/README.md @@ -551,7 +551,10 @@ hey event delete 4821 Without `--calendar`, `hey event list` reads every calendar and `hey event add` files on the first one it can — the personal calendar is in the list HEY serves but refuses events. -A repeating event lists once as the series it is stored as, not once per day it falls on. +The list follows every page HEY serves. Within each calendar HEY orders recordings by +newest start time, not creation time, so use the ID returned by `hey event add` for a follow-up +edit or delete rather than choosing an event by its position in the list. A repeating event +lists once as the series it is stored as, not once per day it falls on. An event with no `--start-time` is an all-day event, and a `--start-time` with no `--end-time` runs for an hour. Clock times are read in `--time-zone`, which defaults to the diff --git a/go.mod b/go.mod index 7d5c1ef7..f218f7fd 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( charm.land/glamour/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.6 github.com/basecamp/actioncable-go v0.0.0-20260824145920-822e6cf08655 - github.com/basecamp/hey-sdk/go v0.24.0 + github.com/basecamp/hey-sdk/go v0.25.0 github.com/charmbracelet/x/ansi v0.11.8 github.com/fsnotify/fsnotify v1.10.1 github.com/gofrs/flock v0.13.0 diff --git a/go.sum b/go.sum index 4f554c28..9f436717 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/basecamp/actioncable-go v0.0.0-20260824145920-822e6cf08655 h1:zz0WUSEmjURj0T+soXuTtgX291nYouqa+UoyYY3Xxk8= github.com/basecamp/actioncable-go v0.0.0-20260824145920-822e6cf08655/go.mod h1:ezaV5z1GXQAsqyejqTs6wCFl2D8Wj+COLQkHc/kwoRs= -github.com/basecamp/hey-sdk/go v0.24.0 h1:yEKHXAcX2yGhiphe6NetBLlfxtjJoM8r8pat3N1lYxs= -github.com/basecamp/hey-sdk/go v0.24.0/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk= +github.com/basecamp/hey-sdk/go v0.25.0 h1:d1dHUXzMimExHeGqAp10/BsbK0j9b99m8D0WO3kbVkA= +github.com/basecamp/hey-sdk/go v0.25.0/go.mod h1:k6sO2XhMkU3UY8lD2ozp0735Ic3q8xoMQt7YUT3TlYk= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= diff --git a/internal/cmd/calendar_commands_test.go b/internal/cmd/calendar_commands_test.go index 8e475219..3e14508a 100644 --- a/internal/cmd/calendar_commands_test.go +++ b/internal/cmd/calendar_commands_test.go @@ -169,6 +169,102 @@ func TestEventsListReadsEveryCalendar(t *testing.T) { } } +// Calendar recordings are geared-paginated newest start first. A recurring series that +// began long ago can sit behind a full page of newer one-off events even while it recurs in +// the requested window, so stopping at the first page makes the series disappear. +func TestEventsListFollowsEveryRecordingsPage(t *testing.T) { + var pages []string + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page := r.URL.Query().Get("page") + pages = append(pages, page) + w.Header().Set("Content-Type", "application/json") + switch page { + case "": + w.Header().Set("Link", `; rel="next"`) + _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":1,"title":"Design review","starts_at":"2026-08-03T09:00:00Z"}]}`) + case "older-starts": + _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":2,"title":"Weekly planning","recurring":true,"starts_at":"2025-01-06T09:00:00Z"}]}`) + default: + http.Error(w, "unexpected page", http.StatusBadRequest) + } + }) + + ids, err := runFormattedCommand(t, handler, []string{"--ids-only"}, + "event", "list", "--calendar", "7", "--starts-on", "2026-08-01", "--ends-on", "2026-08-31") + if err != nil { + t.Fatalf("execute events list --ids-only: %v", err) + } + if got := strings.Join(pages, ","); got != ",older-starts" { + t.Errorf("pages = %q, want the first page followed by HEY's cursor", got) + } + if got := strings.Fields(ids); len(got) != 2 || got[0] != "1" || got[1] != "2" { + t.Errorf("ids = %q, want the one-off event and the recurring series from page two", ids) + } +} + +// An empty page ends a geared-paginated list even when it carries another cursor. Following +// that cursor can make an empty last page cycle back into pages already read. +func TestEventsListStopsOnAnEmptyRecordingsPage(t *testing.T) { + var pages []string + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page := r.URL.Query().Get("page") + pages = append(pages, page) + w.Header().Set("Content-Type", "application/json") + switch page { + case "": + w.Header().Set("Link", `; rel="next"`) + _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":1,"title":"Design review"}]}`) + case "empty-page": + w.Header().Set("Link", `; rel="next"`) + _, _ = io.WriteString(w, `{}`) + default: + http.Error(w, "unexpected page", http.StatusBadRequest) + } + }) + + ids, err := runFormattedCommand(t, handler, []string{"--ids-only"}, + "event", "list", "--calendar", "7", "--starts-on", "2026-08-01", "--ends-on", "2026-08-31") + if err != nil { + t.Fatalf("execute events list --ids-only: %v", err) + } + if got := strings.Join(pages, ","); got != ",empty-page" { + t.Errorf("pages = %q, want the first and empty pages only", got) + } + if got := strings.Fields(ids); len(got) != 1 || got[0] != "1" { + t.Errorf("ids = %q, want the event from the first page", ids) + } +} + +// A server repeating a geared cursor cannot make the CLI read forever. The second page is +// accepted once, then its repeated cursor is refused before another request is made. +func TestEventsListRefusesARepeatedRecordingsCursor(t *testing.T) { + var pages []string + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page := r.URL.Query().Get("page") + pages = append(pages, page) + w.Header().Set("Content-Type", "application/json") + switch page { + case "": + w.Header().Set("Link", `; rel="next"`) + _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":1,"title":"Design review"}]}`) + case "repeated": + w.Header().Set("Link", `; rel="next"`) + _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":2,"title":"Weekly planning"}]}`) + default: + http.Error(w, "unexpected page", http.StatusBadRequest) + } + }) + + _, err := runFormattedCommand(t, handler, []string{"--ids-only"}, + "event", "list", "--calendar", "7", "--starts-on", "2026-08-01", "--ends-on", "2026-08-31") + if err == nil || !strings.Contains(err.Error(), "calendar recordings pagination repeated a page") { + t.Fatalf("error = %v, want the repeated-cursor refusal", err) + } + if got := strings.Join(pages, ","); got != ",repeated" { + t.Errorf("pages = %q, want no request after the cursor repeated", got) + } +} + func TestEventsListDefaultsEndDateFromStart(t *testing.T) { response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.URL.Query().Get("ends_on"); got != "2026-03-03" { diff --git a/internal/cmd/recording_filter.go b/internal/cmd/recording_filter.go index 0b99d2ae..5164b2e5 100644 --- a/internal/cmd/recording_filter.go +++ b/internal/cmd/recording_filter.go @@ -106,26 +106,62 @@ func (f *recordingFilter) resolve(ctx context.Context) (recordingWindow, error) } // read lists the recordings of one type over the window, calendar by calendar in the order -// HEY listed the calendars. +// HEY listed the calendars, following every page of each calendar. // // A recurring event is one recording here rather than one per day it falls on: a calendar // lists what it holds, and only the day, week and year reads expand a recurrence into the -// occurrences inside them. +// occurrences inside them. Series can start long before the requested window, which puts +// them on a later page after newer one-off events even when they recur inside it. func (w recordingWindow) read(ctx context.Context, recType string) ([]generated.Recording, error) { recordings := []generated.Recording{} for _, calendarID := range w.calendars { - resp, err := sdk.Calendars().GetRecordings(ctx, calendarID, &generated.GetCalendarRecordingsParams{ - StartsOn: &w.startsOn, - EndsOn: &w.endsOn, - }) + calendarRecordings, err := w.readCalendar(ctx, calendarID, recType) if err != nil { - return nil, apierr.FromSDK(err) + return nil, err } - recordings = append(recordings, filterRecordingsByType(resp, recType)...) + recordings = append(recordings, calendarRecordings...) } return recordings, nil } +func (w recordingWindow) readCalendar(ctx context.Context, calendarID int64, recType string) ([]generated.Recording, error) { + params := &generated.GetCalendarRecordingsParams{StartsOn: &w.startsOn, EndsOn: &w.endsOn} + recordings := []generated.Recording{} + seenPages := map[string]bool{} + + for { + page, err := sdk.Calendars().GetRecordingsPage(ctx, calendarID, params) + if err != nil { + return nil, apierr.FromSDK(err) + } + if page == nil { + return recordings, nil + } + + recordings = append(recordings, filterRecordingsByType(page.Recordings, recType)...) + if page.NextPage == "" || calendarRecordingsEmpty(page.Recordings) { + return recordings, nil + } + if seenPages[page.NextPage] { + return nil, apierr.ErrAPI(0, "calendar recordings pagination repeated a page") + } + seenPages[page.NextPage] = true + params.Page = &page.NextPage + } +} + +func calendarRecordingsEmpty(recordings *generated.CalendarRecordingsResponse) bool { + if recordings == nil { + return true + } + for _, grouped := range *recordings { + if len(grouped) > 0 { + return false + } + } + return true +} + // describe names the window for a summary line. func (w recordingWindow) describe() string { return fmt.Sprintf("%s to %s", w.startsOn, w.endsOn) diff --git a/nix/package.nix b/nix/package.nix index 1af907fb..43518fa6 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -18,7 +18,7 @@ buildGoModule.override { inherit go; } (finalAttrs: { # To update: run `make update-nix-hash` (Docker). It rewrites this quoted # value in place, so keep it a string literal rather than lib.fakeHash. - vendorHash = "sha256-aQ5nmKDTTEVb2lnVlhMfoVlm4VdsR2hm2YBbvq7Ks1A="; + vendorHash = "sha256-jrvzWPmLQBHEakJvp5crisp1YHVubpTSOST9qQaFung="; subPackages = [ "cmd/hey" ];