| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "time" |
| 8 | |
| 9 | "gorm.io/gorm" |
| 10 | |
| 11 | "amadan.net/oficina/calendar/internal/calendar/seal" |
| 12 | ) |
| 13 | |
| 14 | // The three scopes an edit or a delete can have on a series. |
| 15 | // |
| 16 | // "This one" is the default everywhere, and it is first in every form. |
| 17 | // The expensive answer is last with its count said out loud, because |
| 18 | // the cheap one should never be one keystroke away from the one that |
| 19 | // destroys a year of standups. |
| 20 | const ( |
| 21 | ScopeThis = "this" |
| 22 | ScopeFollowing = "following" |
| 23 | ScopeAll = "all" |
| 24 | ) |
| 25 | |
| 26 | // UpdateOccurrence edits ONE occurrence of a series by writing an |
| 27 | // override row. |
| 28 | // |
| 29 | // The override carries RecurrenceIDLocal = the occurrence's ORIGINAL |
| 30 | // local start, never its new one. That single field is what makes a |
| 31 | // moved occurrence behave: the expander skips the original slot because |
| 32 | // an override claims it, and renders the override at whatever time it |
| 33 | // now has. Key it by the new time instead and the original slot goes |
| 34 | // unclaimed — the day shows the unmoved occurrence AND the moved one. |
| 35 | func (s *Store) UpdateOccurrence(ctx context.Context, personID, seriesID int64, originalLocal string, in EventInput) error { |
| 36 | master, err := s.Event(ctx, personID, seriesID) |
| 37 | if err != nil { |
| 38 | return err |
| 39 | } |
| 40 | if !master.Writable() { |
| 41 | return ErrReadOnly |
| 42 | } |
| 43 | if master.RRule == "" { |
| 44 | // Not a series: editing "this one" is editing the event. |
| 45 | return s.UpdateEvent(ctx, personID, seriesID, in) |
| 46 | } |
| 47 | if err := ValidateEventInput(&in); err != nil { |
| 48 | return err |
| 49 | } |
| 50 | |
| 51 | existing, found, err := s.findOverride(ctx, seriesID, originalLocal) |
| 52 | if err != nil { |
| 53 | return err |
| 54 | } |
| 55 | if found { |
| 56 | // An override already exists for this occurrence; edit it in |
| 57 | // place rather than adding a second one for the same slot. |
| 58 | return s.UpdateEvent(ctx, personID, existing.ID, in) |
| 59 | } |
| 60 | |
| 61 | // A new override inherits the series' identity — same calendar, |
| 62 | // same UID — because to every other calendar it IS the same event, |
| 63 | // just this one instance of it. |
| 64 | startUTC, err := ToUTC(in.StartLocal, in.TZ, in.AllDay) |
| 65 | if err != nil { |
| 66 | return err |
| 67 | } |
| 68 | endUTC, err := ToUTC(in.EndLocal, in.TZ, in.AllDay) |
| 69 | if err != nil { |
| 70 | return err |
| 71 | } |
| 72 | title, err := s.seal.SealString(seal.PurposeEventTitle, in.Title) |
| 73 | if err != nil { |
| 74 | return fmt.Errorf("calendar: seal title: %w", err) |
| 75 | } |
| 76 | loc, err := s.seal.SealString(seal.PurposeEventLocation, in.Location) |
| 77 | if err != nil { |
| 78 | return fmt.Errorf("calendar: seal location: %w", err) |
| 79 | } |
| 80 | notes, err := s.seal.SealString(seal.PurposeEventNotes, in.Notes) |
| 81 | if err != nil { |
| 82 | return fmt.Errorf("calendar: seal notes: %w", err) |
| 83 | } |
| 84 | |
| 85 | now := time.Now().UTC() |
| 86 | ov := Event{ |
| 87 | CalendarID: master.CalendarID, |
| 88 | UIDHash: master.UIDHash, |
| 89 | UIDSealed: master.UIDSealed, |
| 90 | |
| 91 | SeriesID: &seriesID, |
| 92 | RecurrenceIDLocal: originalLocal, |
| 93 | |
| 94 | TitleSealed: title, |
| 95 | LocationSealed: loc, |
| 96 | NotesSealed: notes, |
| 97 | |
| 98 | AllDay: in.AllDay, |
| 99 | StartLocal: in.StartLocal, |
| 100 | EndLocal: in.EndLocal, |
| 101 | TZ: in.TZ, |
| 102 | StartUTC: startUTC, |
| 103 | EndUTC: endUTC, |
| 104 | |
| 105 | // An override never carries the rule. It is one instance, and a |
| 106 | // rule on it would expand a series inside a series. |
| 107 | RRule: "", |
| 108 | Exdates: "", |
| 109 | |
| 110 | Kind: in.Kind, |
| 111 | Status: in.Status, |
| 112 | Sequence: master.Sequence + 1, |
| 113 | CreatedBy: personID, |
| 114 | CreatedAt: now, |
| 115 | UpdatedAt: now, |
| 116 | } |
| 117 | if err := s.g.WithContext(ctx).Create(&ov).Error; err != nil { |
| 118 | return fmt.Errorf("calendar: create override: %w", err) |
| 119 | } |
| 120 | // The reminder for this occurrence now points at a different time. |
| 121 | return s.rescheduleAllFor(ctx, seriesID) |
| 122 | } |
| 123 | |
| 124 | // SplitSeries ends a series the day before originalLocal and starts a |
| 125 | // new one from it — "this and every following". |
| 126 | // |
| 127 | // A split rather than RANGE=THISANDFUTURE. The RFC's range modifier is |
| 128 | // supported by almost nothing that will read our export, and a split is |
| 129 | // two ordinary series that every client already understands. The cost |
| 130 | // is that the tail gets a new UID, which is honest: to an importer it |
| 131 | // IS a different series from that day on. |
| 132 | func (s *Store) SplitSeries(ctx context.Context, personID, seriesID int64, originalLocal string, in EventInput) error { |
| 133 | master, err := s.Event(ctx, personID, seriesID) |
| 134 | if err != nil { |
| 135 | return err |
| 136 | } |
| 137 | if !master.Writable() { |
| 138 | return ErrReadOnly |
| 139 | } |
| 140 | if master.RRule == "" { |
| 141 | return s.UpdateEvent(ctx, personID, seriesID, in) |
| 142 | } |
| 143 | if err := ValidateEventInput(&in); err != nil { |
| 144 | return err |
| 145 | } |
| 146 | |
| 147 | loc, err := Zone(master.TZ) |
| 148 | if err != nil { |
| 149 | return err |
| 150 | } |
| 151 | cut, _, err := ParseLocal(originalLocal, loc) |
| 152 | if err != nil { |
| 153 | return fmt.Errorf("calendar: split at %q: %w", originalLocal, err) |
| 154 | } |
| 155 | |
| 156 | // The head stops the day before the cut. UNTIL is inclusive of its |
| 157 | // whole day, so the day before is the last one the head keeps. |
| 158 | until := cut.AddDate(0, 0, -1).Format("20060102") |
| 159 | headRule := withUntil(master.RRule, until) |
| 160 | |
| 161 | return s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { |
| 162 | headUntil, _ := RuleUntilUTC(headRule, master.StartLocal, master.TZ, master.AllDay) |
| 163 | head := splitWritableEvents(tx, personID, master.CalendarID). |
| 164 | Where("id = ? AND deleted_at IS NULL AND sequence = ?", seriesID, master.Sequence). |
| 165 | Select("rrule", "until_utc", "sequence", "updated_at"). |
| 166 | Updates(map[string]any{ |
| 167 | "rrule": headRule, |
| 168 | "until_utc": headUntil, |
| 169 | "sequence": gorm.Expr("sequence + 1"), |
| 170 | "updated_at": time.Now().UTC(), |
| 171 | }) |
| 172 | if head.Error != nil { |
| 173 | return fmt.Errorf("calendar: end the head series: %w", head.Error) |
| 174 | } |
| 175 | if head.RowsAffected != 1 { |
| 176 | return ErrNotFound |
| 177 | } |
| 178 | |
| 179 | // The tail is a new series with the master's rule, starting at |
| 180 | // the edited time. A new UID: to any importer this is a |
| 181 | // different series from here on, and pretending otherwise |
| 182 | // produces two events claiming one identity. |
| 183 | tail := in |
| 184 | tail.CalendarID = master.CalendarID |
| 185 | tail.RRule = master.RRule |
| 186 | tail.Exdates = "" |
| 187 | ev, err := s.buildEvent(personID, tail) |
| 188 | if err != nil { |
| 189 | return err |
| 190 | } |
| 191 | if err := tx.Create(&ev).Error; err != nil { |
| 192 | return fmt.Errorf("calendar: create the tail series: %w", err) |
| 193 | } |
| 194 | |
| 195 | if err := moveSplitOverrides(tx, personID, master.Event, ev, cut); err != nil { |
| 196 | return err |
| 197 | } |
| 198 | return s.rescheduleAllForTx(tx, seriesID) |
| 199 | }) |
| 200 | } |
| 201 | |
| 202 | // findOverride looks one up by its ORIGINAL occurrence. |
| 203 | func (s *Store) findOverride(ctx context.Context, seriesID int64, originalLocal string) (Event, bool, error) { |
| 204 | var e Event |
| 205 | err := s.g.WithContext(ctx). |
| 206 | Where("series_id = ? AND recurrence_id_local = ? AND deleted_at IS NULL", |
| 207 | seriesID, originalLocal). |
| 208 | First(&e).Error |
| 209 | if errors.Is(err, gorm.ErrRecordNotFound) { |
| 210 | return Event{}, false, nil |
| 211 | } |
| 212 | if err != nil { |
| 213 | return Event{}, false, fmt.Errorf("calendar: find override: %w", err) |
| 214 | } |
| 215 | return e, true, nil |
| 216 | } |
| 217 | |
| 218 | // withUntil replaces or appends an UNTIL on a rule, and drops a COUNT |
| 219 | // if there was one — COUNT and UNTIL together is not a legal rule, and |
| 220 | // a head that kept its original COUNT would go on generating past its |
| 221 | // own end. |
| 222 | func withUntil(rrule, until string) string { |
| 223 | r, err := ParseRule(rrule) |
| 224 | if err != nil { |
| 225 | return rrule |
| 226 | } |
| 227 | out := "FREQ=" + r.Freq |
| 228 | if r.Interval > 1 { |
| 229 | out += fmt.Sprintf(";INTERVAL=%d", r.Interval) |
| 230 | } |
| 231 | if len(r.ByDay) > 0 { |
| 232 | days := make([]string, 0, len(r.ByDay)) |
| 233 | for _, d := range r.ByDay { |
| 234 | days = append(days, ICSWeekday(d)) |
| 235 | } |
| 236 | out += ";BYDAY=" + joinComma(days) |
| 237 | } |
| 238 | if r.Ordinal != 0 { |
| 239 | out += fmt.Sprintf(";BYDAY=%d%s", r.Ordinal, ICSWeekday(r.OrdinalDay)) |
| 240 | } |
| 241 | if r.MonthDay > 0 { |
| 242 | out += fmt.Sprintf(";BYMONTHDAY=%d", r.MonthDay) |
| 243 | } |
| 244 | return out + ";UNTIL=" + until |
| 245 | } |
| 246 | |
| 247 | func joinComma(s []string) string { |
| 248 | out := "" |
| 249 | for i, v := range s { |
| 250 | if i > 0 { |
| 251 | out += "," |
| 252 | } |
| 253 | out += v |
| 254 | } |
| 255 | return out |
| 256 | } |
| 257 | |