oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import (
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.
20const (
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.
35func (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.
132func (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.
203func (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.
222func 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
247func 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