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
9 changes: 7 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
166 changes: 140 additions & 26 deletions internal/cmd/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package cmd

import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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 (<series id>_<YYYY-MM-DD>)")
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")
Expand Down Expand Up @@ -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,
Expand All @@ -442,59 +458,157 @@ 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
}
}
}

return generated.Recording{}, apierr.ErrNotFoundHint("event", strconv.FormatInt(id, 10),
fmt.Sprintf("hey event edit %d <YYYY-MM-DD> 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 {
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading