diff --git a/docs/cli.md b/docs/cli.md index eceee403..f5ee2732 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -497,8 +497,13 @@ changing. Two things cannot survive the round trip. HEY serves notes back as pla saving flattens their formatting; and a countdown is a recording of its own that this edit does not read back, so an edit removes one unless `--countdown` names it again. An event that cannot be read is refused rather than written blind. It is looked for within a year either side of today; pass the day it -starts (`hey event edit 4821 2026-09-02`) for one outside that — `--calendar` only limits -which calendars are read. +starts (`hey event edit 4821 2026-09-02`) for one outside that. Every calendar is read, and +`--calendar` is where the event moves to: `hey event edit 4821 --calendar 9102`. That has to +be a calendar you own or share, not your personal calendar or a subscription. An event you +cannot edit, such as an invitation, moves only onto a calendar nobody else is on, and an +event on a subscription does not move at all; HEY keeps it where it is otherwise, and the +edit fails saying so rather than reporting it updated. HEY still saves the circle, countdown +and reminders of an event it does not move. An id on its own changes the whole event, a repeating series included. One day of a series is changed with `--occurrence`, which takes the `occurrence_id` that `hey event diff --git a/internal/cmd/events.go b/internal/cmd/events.go index 90a09087..df436516 100644 --- a/internal/cmd/events.go +++ b/internal/cmd/events.go @@ -2,7 +2,9 @@ package cmd import ( "context" + "errors" "fmt" + "net/http" "strconv" "strings" "time" @@ -272,9 +274,16 @@ the clocks repeat as they go back names two moments, of which HEY takes the dayl one, or the later where neither is; a time you keep at the other, or with seconds, cannot be sent, so the edit refuses until you retype it. All of this holds for an --occurrence edit too. -The event is found by reading the calendars it might be on, which is one request each and +The event is found by reading every calendar it might be on, which is one request each and covers the pages HEY answers with. Give the day it starts as [date] to look on that day -alone, or --calendar to look on one calendar. +alone. + +--calendar moves the event to that calendar, which has to be one you own or share: not +your personal calendar and not a subscription. An event you cannot edit, such as an +invitation, moves only onto a calendar nobody else is on, and an event on a subscription +does not move at all; HEY keeps it where it is otherwise, and the edit says so rather than +reporting it updated. HEY still saves the circle, countdown and reminders of an event it +does not move. An id alone changes the whole event, a repeating series included. One day of a series is changed with --occurrence, which takes the occurrence_id 'hey event day' and 'hey event @@ -318,6 +327,7 @@ or one day.`, hey event edit 4821 --starts-on 2026-09-04 --start-time 15:00 hey event edit 4821 2026-09-02 --location "Studio, 3rd floor" hey event edit 4821 --circle=false + hey event edit 4821 --calendar 9102 hey event edit 4821 --occurrence 4821_2026-09-15 --apply-to current --start-time 15:00 --json hey event edit 4821 --occurrence 4821_2026-09-15 --apply-to future --repeat every_week --repeat-times 8 --location "Studio, 3rd floor" --allow-plain-notes`, RunE: eventsEditCommand.run, @@ -328,6 +338,7 @@ or one day.`, flags := eventsEditCommand.cmd.Flags() // An edit only falls back to the account's zone for an event that has none of its own. flags.Lookup("time-zone").Usage = "IANA zone the times are written in, such as America/New_York (defaults to the event's own zone, else your HEY account's)" + flags.Lookup("calendar").Usage = "Calendar ID to move the event to" flags.StringVar(&eventsEditCommand.occurrence, "occurrence", "", "One day of a repeating event, by the occurrence_id 'hey event day' serves (_)") flags.StringVar(&eventsEditCommand.applyTo, "apply-to", "", "How much of the series an --occurrence edit reaches: current (that day alone) or future (that day and every one after it)") flags.BoolVar(&eventsEditCommand.allowPlainNotes, "allow-plain-notes", false, "Let an --occurrence edit send notes it is not changing back as plain text, losing their formatting") @@ -433,7 +444,12 @@ func (c *eventsEditCommand) run(cmd *cobra.Command, args []string) error { result, err := sdk.CalendarEvents().Update(ctx, id, changes) if err != nil { - return apierr.FromSDK(err) + return eventWriteError(err, id, changes.CalendarID) + } + if changes.CalendarID != nil { + if err = checkMoved(ctx, event, schedule.startsAt, *changes.CalendarID, result); err != nil { + return err + } } return writeMutationLine(cmd, @@ -442,28 +458,128 @@ func (c *eventsEditCommand) run(cmd *cobra.Command, args []string) error { result) } +// eventWriteError says what HEY's refusal of a move can mean. HEY files only on a calendar you +// own or share, and answers 404 for any other — the personal calendar and a subscription among +// them — as it does when an event is added there. The same 404 answers an event deleted since +// it was read, and nothing in it says which, so the refusal names both. +func eventWriteError(err error, id int64, calendarID *int64) error { + if calendarID != nil && hey.AsError(err).HTTPStatus == http.StatusNotFound { + return &apierr.Error{ + Code: apierr.CodeNotFound, + Message: fmt.Sprintf("HEY answered not found moving event %d to calendar %d", id, *calendarID), + Hint: "HEY files an event only on a calendar you own or share, not your personal calendar or a subscription, and answers not found for any other or for an event deleted since it was read; hey calendar list lists them", + HTTPStatus: http.StatusNotFound, + Cause: err, + } + } + return apierr.FromSDK(err) +} + +// checkMoved says so when HEY answers a move by leaving the event where it was. HEY does not +// refuse a calendar it will not move an event to: an event you may not edit, such as an +// invitation, moves only onto a calendar nobody else is on and never off a subscription, and +// otherwise HEY drops the calendar from the update and answers with the event unmoved. +// Reporting that as updated would be the one wrong answer, so the event HEY answers with is +// read for its calendar. The rest of the update is not dropped with it — the circle, the +// countdown and the reminders are an invitee's to set — so the refusal says so. +// +// HEY's JSON answer always names the calendar. An older server redirects instead, and the +// SDK then hands back only the id, so the event is read again, around the day it started on +// and the day it was asked to start on: HEY may have kept either. +func checkMoved(ctx context.Context, was generated.Recording, startsOn string, calendarID int64, result *generated.Recording) error { + onCalendar := int64(0) + if result != nil { + onCalendar = result.Calendar.Id + } + if onCalendar == 0 { + event, err := readMovedEvent(ctx, was, startsOn) + if err != nil { + return movedReadBackError(was.Id, calendarID, err) + } + onCalendar = event.Calendar.Id + } + if onCalendar == calendarID { + return nil + } + return &apierr.Error{ + Code: apierr.CodeForbidden, + Message: fmt.Sprintf("HEY did not move event %d to calendar %d; it is still on calendar %d", was.Id, calendarID, onCalendar), + Hint: "an event you cannot edit, such as an invitation, moves only onto a calendar nobody else is on and never off a subscription; HEY may still have saved the circle, countdown and reminders this edit sent", + } +} + +// readMovedEvent reads an event back after a write HEY answered with its id alone. The days +// are UTC days, and an event keeps its own zone's date, so each end is read a day wide either +// way: a morning in Tokyo or an evening in Los Angeles sits on the neighbouring UTC day. +func readMovedEvent(ctx context.Context, was generated.Recording, startsOn string) (generated.Recording, error) { + from := calendarDay(was.StartsAt.UTC()) + to := from + if day, err := time.Parse(dateLayout, startsOn); err == nil { + if day.Before(from) { + from = day + } + if day.After(to) { + to = day + } + } + filter := recordingFilter{ + startsOn: from.AddDate(0, 0, -1).Format(dateLayout), + endsOn: to.AddDate(0, 0, 2).Format(dateLayout), + defaultWindow: eventSearchYear, + defaultCalendars: allCalendarIDs, + } + window, err := filter.resolve(ctx) + if err != nil { + return generated.Recording{}, err + } + return findEventIn(ctx, window, was.Id) +} + +// movedReadBackError is a read-back that failed after the write went through. It keeps the +// failure's own code, so an expired sign-in or a rate limit still exits as one, and says the +// write was made so nobody runs it again to find out. +func movedReadBackError(id, calendarID int64, err error) error { + var readErr *apierr.Error + if !errors.As(err, &readErr) { + readErr = &apierr.Error{Code: apierr.CodeAPI, Message: err.Error()} + } + wrapped := *readErr + wrapped.Message = fmt.Sprintf("HEY saved event %d without saying which calendar it is on, and reading it back failed: %s", id, readErr.Message) + wrapped.Hint = fmt.Sprintf("hey event list --calendar %d shows whether it moved", calendarID) + wrapped.Cause = err + return &wrapped +} + // findEvent reads the event so the update can send back what it is not changing. HEY serves -// no event on its own, so it is looked for among the recordings of the calendars it could be -// on — every one of them, or the one --calendar names, over the day given or a window wide -// enough to cover an event somebody is editing. +// no event on its own, so it is looked for among the recordings of every calendar it could be +// on, over the day given or a window wide enough to cover an event somebody is editing. +// --calendar is not one of them: it is where the event is moving to, which is the one +// calendar it is not on yet. // // The day is read as the window [day, day+1): HEY's recordings window is a pair of // instants, and the degenerate same-day window — midnight to the same midnight — can // never contain a timed event, which is how editing an event by its own day used to // answer not-found. Reading a day too many is harmless here: the event is matched by id. func (c *eventsEditCommand) findEvent(ctx context.Context, id int64, on string) (generated.Recording, error) { - window, err := c.searchWindow(ctx, on) + window, err := eventSearchWindow(ctx, on) if err != nil { return generated.Recording{}, err } + return findEventIn(ctx, window, id) +} - events, err := window.read(ctx, recordingTypeEvent) - if err != nil { - return generated.Recording{}, err - } - for _, event := range events { - if event.Id == id { - return event, nil +// findEventIn looks for the event calendar by calendar and stops at the one it is on, so a +// calendar listed after it that cannot be read does not stand in the way of an edit. +func findEventIn(ctx context.Context, window recordingWindow, id int64) (generated.Recording, error) { + for _, calendarID := range window.calendars { + events, err := window.readCalendar(ctx, calendarID, []string{recordingTypeEvent}) + if err != nil { + return generated.Recording{}, err + } + for _, event := range events { + if event.Id == id { + return event, nil + } } } @@ -471,30 +587,28 @@ func (c *eventsEditCommand) findEvent(ctx context.Context, id int64, on string) fmt.Sprintf("hey event edit %d reads the day it starts on", id)) } -// searchWindow is where an edit looks for its event: the day given, read as [day, day+1), -// or a window wide enough to cover an event somebody is editing, over the calendar -// --calendar names or every one of them. -func (c *eventsEditCommand) searchWindow(ctx context.Context, on string) (recordingWindow, error) { - return eventSearchWindow(ctx, c.fields.calendar, on) -} - // eventSearchWindow is where an event given by id is looked for: the day given, read as -// [day, day+1), or a year either side of today, over one calendar or every one of them. -func eventSearchWindow(ctx context.Context, calendar int64, on string) (recordingWindow, error) { +// [day, day+1), or a year either side of today, over every calendar. +func eventSearchWindow(ctx context.Context, on string) (recordingWindow, error) { endsOn := on if day, err := time.Parse(dateLayout, on); err == nil { endsOn = day.AddDate(0, 0, 1).Format(dateLayout) } filter := recordingFilter{ - calendar: calendar, startsOn: on, endsOn: endsOn, - defaultWindow: func(today time.Time) (time.Time, time.Time) { return today.AddDate(-1, 0, 0), today.AddDate(1, 0, 0) }, + defaultWindow: eventSearchYear, defaultCalendars: allCalendarIDs, } return filter.resolve(ctx) } +// eventSearchYear is a year either side of today, wide enough to cover an event somebody is +// editing without being told the day it is on. +func eventSearchYear(today time.Time) (time.Time, time.Time) { + return today.AddDate(-1, 0, 0), today.AddDate(1, 0, 0) +} + // delete type eventsDeleteCommand struct { @@ -622,7 +736,7 @@ func (c *eventsDeleteCommand) deleteOccurrence(ctx context.Context, cmd *cobra.C // event, so it is looked for where an edit looks, and an id that is not found there is // deleted as it always was: whatever it is, it is not a day of a series within a year. func refuseADayOfASeries(ctx context.Context, cmd *cobra.Command, id int64) error { - window, err := eventSearchWindow(ctx, 0, "") + window, err := eventSearchWindow(ctx, "") if err != nil { return err } diff --git a/internal/cmd/events_test.go b/internal/cmd/events_test.go index d8262ebe..e1bde982 100644 --- a/internal/cmd/events_test.go +++ b/internal/cmd/events_test.go @@ -283,6 +283,8 @@ func TestEventsEditAllDayEventOmitsClockTimes(t *testing.T) { _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Work","owned":true}}]}`) case r.URL.Path == "/calendars/9/recordings.json": _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":4821,"title":"Sarah's birthday","all_day":true,"starts_at":"2026-09-02T00:00:00Z","ends_at":"2026-09-02T00:00:00Z"}]}`) case r.Method == http.MethodPatch && r.URL.Path == "/calendar/events/4821.json": @@ -300,7 +302,7 @@ func TestEventsEditAllDayEventOmitsClockTimes(t *testing.T) { t.Errorf("unexpected request = %s %s", r.Method, r.URL.Path) http.NotFound(w, r) } - }), "event", "edit", "4821", "2026-09-02", "--calendar", "9", "--title", "Sarah's birthday (edited)") + }), "event", "edit", "4821", "2026-09-02", "--title", "Sarah's birthday (edited)") if err != nil { t.Fatalf("execute all-day event edit: %v", err) } @@ -313,6 +315,8 @@ func TestEventsEditReadsTheDayItIsGiven(t *testing.T) { _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Work","owned":true}}]}`) case r.URL.Path == "/calendars/9/recordings.json": if got := r.URL.Query().Get("starts_on"); got != "2026-09-02" { t.Errorf("starts_on = %q", got) @@ -330,12 +334,252 @@ func TestEventsEditReadsTheDayItIsGiven(t *testing.T) { t.Errorf("unexpected request = %s %s", r.Method, r.URL.Path) http.NotFound(w, r) } - }), "event", "edit", "4821", "2026-09-02", "--calendar", "9", "--location", "Studio, 3rd floor") + }), "event", "edit", "4821", "2026-09-02", "--location", "Studio, 3rd floor") if err != nil { t.Fatalf("execute events edit: %v", err) } } +// --calendar is where the event moves to, so it is not where the event is looked for: the +// event is found on the calendar it is on now, and the update names the one it moves to. +func TestEventsEditMovesTheEventToAnotherCalendar(t *testing.T) { + var read []string + response, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":12,"name":"Nina","owned":true}},{"calendar":{"id":9,"name":"Elena","owned":true}}]}`) + case r.URL.Path == "/calendars/9/recordings.json": + read = append(read, r.URL.Path) + _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":4821,"title":"Parent-teacher conference","starts_at":"2026-10-01T15:00:00Z","ends_at":"2026-10-01T15:30:00Z","calendar":{"id":9}}]}`) + case r.URL.Path == "/calendars/12/recordings.json": + read = append(read, r.URL.Path) + _, _ = io.WriteString(w, `{}`) + case r.Method == http.MethodPatch && r.URL.Path == "/calendar/events/4821.json": + form := eventForm(t, r) + if got := form.Get("calendar_event[calendar_id]"); got != "12" { + t.Errorf("calendar_id = %q, want the calendar it moves to", got) + } + if got := form.Get("calendar_event[summary]"); got != "Parent-teacher conference" { + t.Errorf("summary = %q, want the title kept", got) + } + _, _ = io.WriteString(w, `{"id":4821,"title":"Parent-teacher conference","calendar":{"id":12}}`) + default: + t.Errorf("unexpected request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }), "event", "edit", "4821", "2026-10-01", "--calendar", "12") + if err != nil { + t.Fatalf("execute event edit: %v", err) + } + if !response.OK { + t.Errorf("response = %+v, want ok", response) + } + if want := "/calendars/12/recordings.json /calendars/9/recordings.json"; strings.Join(read, " ") != want { + t.Errorf("read %v, want the calendar it moves to and then the one it is on", read) + } +} + +// HEY does not refuse a move it will not make — an invitation moves only onto a calendar +// nobody else is on — it answers with the event where it was. That is not an update. +func TestEventsEditSaysSoWhenHEYKeepsTheEventWhereItWas(t *testing.T) { + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Elena","owned":true}},{"calendar":{"id":12,"name":"Family","owned":true}}]}`) + case r.URL.Path == "/calendars/9/recordings.json": + _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":4821,"title":"Swim meet","starts_at":"2026-10-03T08:00:00Z","ends_at":"2026-10-03T11:00:00Z","calendar":{"id":9}}]}`) + case r.URL.Path == "/calendars/12/recordings.json": + _, _ = io.WriteString(w, `{}`) + case r.Method == http.MethodPatch && r.URL.Path == "/calendar/events/4821.json": + _, _ = io.WriteString(w, `{"id":4821,"title":"Swim meet","calendar":{"id":9}}`) + default: + t.Errorf("unexpected request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }), "event", "edit", "4821", "2026-10-03", "--calendar", "12") + + var apiErr *apierr.Error + if !errors.As(err, &apiErr) || apiErr.Code != apierr.CodeForbidden { + t.Fatalf("error = %v, want a forbidden refusal", err) + } + if want := "HEY did not move event 4821 to calendar 12; it is still on calendar 9"; apiErr.Message != want { + t.Errorf("message = %q, want %q", apiErr.Message, want) + } +} + +// An older HEY redirects after an update rather than answering with the event, so the answer +// names no calendar; the event is read again on the day it starts to see where it is. +func TestEventsEditReadsAMoveBackWhenHEYDoesNotSayWhereTheEventIs(t *testing.T) { + for _, tt := range []struct { + name, onCalendar, wantErr string + }{ + {name: "moved", onCalendar: "12"}, + {name: "kept where it was", onCalendar: "9", wantErr: "HEY did not move event 4821 to calendar 12; it is still on calendar 9"}, + } { + t.Run(tt.name, func(t *testing.T) { + moved := false + event := func(calendar string) string { + return `{"Calendar::Event":[{"id":4821,"title":"Swim meet","starts_at":"2026-10-03T08:00:00Z","ends_at":"2026-10-03T11:00:00Z","calendar":{"id":` + calendar + `}}]}` + } + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Elena","owned":true}},{"calendar":{"id":12,"name":"Nina","owned":true}}]}`) + case r.URL.Path == "/calendars/9/recordings.json": + if !moved || tt.onCalendar == "9" { + _, _ = io.WriteString(w, event("9")) + return + } + _, _ = io.WriteString(w, `{}`) + case r.URL.Path == "/calendars/12/recordings.json": + if moved && tt.onCalendar == "12" { + _, _ = io.WriteString(w, event("12")) + return + } + _, _ = io.WriteString(w, `{}`) + case r.Method == http.MethodPatch && r.URL.Path == "/calendar/events/4821.json": + moved = true + _, _ = io.WriteString(w, `{"id":4821}`) + default: + t.Errorf("unexpected request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }), "event", "edit", "4821", "2026-10-03", "--calendar", "12") + + if tt.wantErr == "" { + if err != nil { + t.Fatalf("execute event edit: %v", err) + } + return + } + var apiErr *apierr.Error + if !errors.As(err, &apiErr) || apiErr.Code != apierr.CodeForbidden || apiErr.Message != tt.wantErr { + t.Fatalf("error = %v, want %q", err, tt.wantErr) + } + }) + } +} + +// The read-back reads UTC days, and HEY may have kept the day the event started on as well +// as the one it was asked to start on, so it reads from a day before the earlier to a day +// after the later. +func TestEventsEditReadsAMoveBackAroundBothDays(t *testing.T) { + var window []string + moved := false + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Elena","owned":true}},{"calendar":{"id":12,"name":"Nina","owned":true}}]}`) + case r.URL.Path == "/calendars/9/recordings.json" && !moved: + _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":4821,"title":"School trip","all_day":true,"starts_at":"2026-10-03T00:00:00Z","ends_at":"2026-10-03T00:00:00Z","calendar":{"id":9}}]}`) + case r.URL.Path == "/calendars/9/recordings.json": + window = []string{r.URL.Query().Get("starts_on"), r.URL.Query().Get("ends_on")} + _, _ = io.WriteString(w, `{}`) + case r.URL.Path == "/calendars/12/recordings.json" && moved: + _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":4821,"title":"School trip","all_day":true,"starts_at":"2026-10-10T00:00:00Z","ends_at":"2026-10-10T00:00:00Z","calendar":{"id":12}}]}`) + case r.Method == http.MethodPatch && r.URL.Path == "/calendar/events/4821.json": + moved = true + _, _ = io.WriteString(w, `{"id":4821}`) + default: + t.Errorf("unexpected request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }), "event", "edit", "4821", "2026-10-03", "--calendar", "12", "--starts-on", "2026-10-10", "--ends-on", "2026-10-10") + if err != nil { + t.Fatalf("execute event edit: %v", err) + } + if want := []string{"2026-10-02", "2026-10-12"}; strings.Join(window, " ") != strings.Join(want, " ") { + t.Errorf("read back %v, want %v", window, want) + } +} + +// A read-back that fails after the write went through keeps its own code, so a caller can +// tell an expired sign-in or a missing calendar from HEY refusing the move. +func TestEventsEditKeepsTheCodeOfAFailedReadBack(t *testing.T) { + moved := false + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Elena","owned":true}}]}`) + case r.URL.Path == "/calendars/9/recordings.json" && !moved: + _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":4821,"title":"Swim meet","all_day":true,"starts_at":"2026-10-03T00:00:00Z","ends_at":"2026-10-03T00:00:00Z","calendar":{"id":9}}]}`) + case r.URL.Path == "/calendars/9/recordings.json": + http.NotFound(w, r) + case r.Method == http.MethodPatch && r.URL.Path == "/calendar/events/4821.json": + moved = true + _, _ = io.WriteString(w, `{"id":4821}`) + default: + t.Errorf("unexpected request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }), "event", "edit", "4821", "2026-10-03", "--calendar", "12") + + var apiErr *apierr.Error + if !errors.As(err, &apiErr) || apiErr.Code != apierr.CodeNotFound { + t.Fatalf("error = %v, want the read-back's own not-found", err) + } + if want := "HEY saved event 4821 without saying which calendar it is on"; !strings.HasPrefix(apiErr.Message, want) { + t.Errorf("message = %q, want it to start %q", apiErr.Message, want) + } +} + +// The event is looked for calendar by calendar, so a calendar after the one it is on that +// cannot be read is never asked. +func TestEventsEditStopsAtTheCalendarTheEventIsOn(t *testing.T) { + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Elena","owned":true}},{"calendar":{"id":12,"name":"Nina","owned":true}}]}`) + case r.URL.Path == "/calendars/9/recordings.json": + _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":4821,"title":"Swim meet","all_day":true,"starts_at":"2026-10-03T00:00:00Z","ends_at":"2026-10-03T00:00:00Z","calendar":{"id":9}}]}`) + case r.Method == http.MethodPatch && r.URL.Path == "/calendar/events/4821.json": + _, _ = io.WriteString(w, `{"id":4821,"title":"Swim meet (moved)","calendar":{"id":9}}`) + default: + t.Errorf("unexpected request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }), "event", "edit", "4821", "2026-10-03", "--title", "Swim meet (moved)") + if err != nil { + t.Fatalf("execute event edit: %v", err) + } +} + +// HEY answers 404 for a calendar it will not file on — the personal calendar, a subscription — +// and for an event deleted since it was read, so the refusal names the move rather than +// guessing which. +func TestEventsEditNamesTheCalendarHEYWillNotFileOn(t *testing.T) { + _, err := runJSONCommand(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/calendars.json": + _, _ = io.WriteString(w, `{"calendars":[{"calendar":{"id":9,"name":"Elena","owned":true}},{"calendar":{"id":14,"name":"US Holidays","owned":true,"external":true}}]}`) + case r.URL.Path == "/calendars/9/recordings.json": + _, _ = io.WriteString(w, `{"Calendar::Event":[{"id":4821,"title":"Swim meet","starts_at":"2026-10-03T08:00:00Z","ends_at":"2026-10-03T11:00:00Z","calendar":{"id":9}}]}`) + case r.URL.Path == "/calendars/14/recordings.json": + _, _ = io.WriteString(w, `{}`) + case r.Method == http.MethodPatch && r.URL.Path == "/calendar/events/4821.json": + http.NotFound(w, r) + default: + t.Errorf("unexpected request = %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + }), "event", "edit", "4821", "2026-10-03", "--calendar", "14") + + var apiErr *apierr.Error + if !errors.As(err, &apiErr) || apiErr.Code != apierr.CodeNotFound { + t.Fatalf("error = %v, want a not-found naming the calendar", err) + } + if want := "HEY answered not found moving event 4821 to calendar 14"; apiErr.Message != want { + t.Errorf("message = %q, want %q", apiErr.Message, want) + } +} + // Writing blind would clear the notes, location, link and reminders of whichever event 4821 // turns out to be, so an event that cannot be read is not written. func TestEventsEditRefusesAnEventItCannotRead(t *testing.T) { diff --git a/internal/cmd/events_zone_test.go b/internal/cmd/events_zone_test.go index 2aef90af..a801546b 100644 --- a/internal/cmd/events_zone_test.go +++ b/internal/cmd/events_zone_test.go @@ -396,7 +396,7 @@ func TestEventsAddDefaultsToTodayInTheAccountZone(t *testing.T) { func runZoneEdit(t *testing.T, fixture zoneFixture, args ...string) *zoneRequests { t.Helper() handler, requests := zoneServer(t, fixture) - _, err := runJSONCommand(t, handler, append([]string{"event", "edit", "4821", "2026-10-14", "--calendar", "9"}, args...)...) + _, err := runJSONCommand(t, handler, append([]string{"event", "edit", "4821", "2026-10-14"}, args...)...) if err != nil { t.Fatalf("execute event edit: %v", err) } @@ -476,7 +476,7 @@ func TestEventsEditZonedEventKeepsItsZone(t *testing.T) { func TestEventsEditRefusesAStoredZoneItCannotLoad(t *testing.T) { handler, requests := zoneServer(t, zoneFixture{accountZone: "America/New_York", event: strings.ReplaceAll(zonedEventJSON, "Europe/Zagreb", "Mars/Olympus_Mons")}) - _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2026-10-14", "--calendar", "9", "--title", "Dentist appointment (moved)") + _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2026-10-14", "--title", "Dentist appointment (moved)") var cliErr *apierr.Error if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeUsage || !strings.Contains(cliErr.Message, "Mars/Olympus_Mons") { t.Fatalf("error = %v, want a usage error naming the zone", err) @@ -525,7 +525,7 @@ func TestEventsEditZonelessSeriesKeepsTheStartItWasNotGiven(t *testing.T) { series := `{"id":4821,"title":"Night shift handover","recurring":true,"starts_at":"2026-11-01T05:30:00Z","ends_at":"2026-11-01T06:30:00Z",` + `"recurrence_schedule":{"kind":"every_week","preset":true}}` handler, requests := zoneServer(t, zoneFixture{accountZone: "America/New_York", event: series}) - _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2026-11-01", "--calendar", "9", "--end-time", "03:00") + _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2026-11-01", "--end-time", "03:00") if err != nil { t.Fatalf("execute event edit: %v", err) } @@ -586,7 +586,7 @@ func TestEventsEditZonelessEventMadeAllDayTakesTheAccountsDate(t *testing.T) { // A zoneless edit that needs a zone and cannot get one is refused, and nothing is written. func TestEventsEditRefusesWithoutAnAccountZone(t *testing.T) { handler, requests := zoneServer(t, zoneFixture{event: zonelessEventJSON}) - _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2026-10-14", "--calendar", "9", "--start-time", "11:00") + _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2026-10-14", "--start-time", "11:00") wantNoZoneRefusal(t, err, "your HEY account has no time zone set") if got := requests.writes.Load(); got != 0 { t.Errorf("writes = %d, want none", got) @@ -694,7 +694,7 @@ func TestEventsEditRefusesToMoveAKeptTimeOutOfTheRepeatedHour(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler, requests := zoneServer(t, zoneFixture{accountZone: "America/New_York", event: tt.event}) - _, err := runJSONCommand(t, handler, append([]string{"event", "edit", "4821", "2026-11-01", "--calendar", "9"}, tt.args...)...) + _, err := runJSONCommand(t, handler, append([]string{"event", "edit", "4821", "2026-11-01"}, tt.args...)...) if tt.want == "" { var cliErr *apierr.Error if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeUsage || !strings.Contains(cliErr.Message, "clocks repeat") { @@ -800,7 +800,7 @@ func TestEventsEditKeepsATimeHEYTakesTheLaterOf(t *testing.T) { event := `{"id":4821,"title":"Late call with Aigerim","starts_at":"` + tt.start + `","ends_at":"2024-02-29T20:00:00Z",` + `"starts_at_time_zone":"Asia/Almaty","ends_at_time_zone":"Asia/Almaty"}` handler, requests := zoneServer(t, zoneFixture{event: event}) - _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2024-02-29", "--calendar", "9", "--title", "Late call with Aigerim (moved)") + _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2024-02-29", "--title", "Late call with Aigerim (moved)") if !tt.kept { var cliErr *apierr.Error if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeUsage || !strings.Contains(cliErr.Message, "clocks repeat") { @@ -860,7 +860,7 @@ func TestEventsEditRefusesAnEventThatEndsBeforeItStarts(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { handler, requests := zoneServer(t, zoneFixture{accountZone: "America/New_York", event: tt.event}) - _, err := runJSONCommand(t, handler, append([]string{"event", "edit", "4821", "2026-10-14", "--calendar", "9"}, tt.args...)...) + _, err := runJSONCommand(t, handler, append([]string{"event", "edit", "4821", "2026-10-14"}, tt.args...)...) var cliErr *apierr.Error if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeUsage || !strings.Contains(cliErr.Message, tt.want) { t.Fatalf("error = %v, want a usage error containing %q", err, tt.want) @@ -919,7 +919,7 @@ func TestEventsEditRefusesToDropAKeptTimesSeconds(t *testing.T) { } { t.Run(tt.name, func(t *testing.T) { handler, requests := zoneServer(t, zoneFixture{event: tt.event}) - _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2026-10-14", "--calendar", "9", "--title", "Dentist appointment (moved)") + _, err := runJSONCommand(t, handler, "event", "edit", "4821", "2026-10-14", "--title", "Dentist appointment (moved)") var cliErr *apierr.Error if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeUsage || cliErr.Message != tt.want { t.Fatalf("error = %v, want %q", err, tt.want) @@ -945,7 +945,7 @@ func TestEventsEditSaysHowFarARepeatedTimeWouldMove(t *testing.T) { } { t.Run(tt.name, func(t *testing.T) { handler, requests := zoneServer(t, zoneFixture{event: tt.event}) - _, err := runJSONCommand(t, handler, "event", "edit", "4821", tt.day, "--calendar", "9", "--title", "Night shift handover (Sam)") + _, err := runJSONCommand(t, handler, "event", "edit", "4821", tt.day, "--title", "Night shift handover (Sam)") var cliErr *apierr.Error if !errors.As(err, &cliErr) || cliErr.Code != apierr.CodeUsage || !strings.Contains(cliErr.Message, tt.want) { t.Fatalf("error = %v, want a usage error saying it %s", err, tt.want) diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index 94317d66..a32272bd 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -853,8 +853,13 @@ notes come back as plain text, so their formatting is flattened, and a countdown recording of its own that the whole-event edit does not read back, so an edit removes one unless `--countdown` names it again. The event is looked for within a year either side of today and refused rather than written blind if it is not found — pass the day it starts -(`hey event edit 4821 2026-09-02`) for one outside that; `--calendar` only limits which -calendars are read. +(`hey event edit 4821 2026-09-02`) for one outside that. Every calendar is read, and +`--calendar` moves the event there (`hey event edit 4821 --calendar 9102`) — a calendar you +own or share, not the personal calendar or a subscription (HEY answers `not_found` otherwise). An event +you cannot edit, such as an invitation, moves only onto a calendar nobody else is on, and +an event on a subscription does not move at all; HEY keeps it where it is otherwise, and +the edit fails with `forbidden` rather than reporting it updated (the circle, countdown and +reminders it sent may still have been saved). **An id alone edits the whole series; one day of it is `--occurrence` plus `--apply-to`.** `--occurrence` takes the `occurrence_id` from `day` or `week` exactly as served