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 "net/http"
5 "strconv"
6 "strings"
7 "time"
8)
9
10// eventForm is the create/edit form's view data. It carries the
11// submitted values back on a validation failure, so a rejected form
12// never loses what somebody typed — the single most common way a form
13// wastes a person's time.
14type eventForm struct {
15 Action string
16 Title string
17 Heading string
18 Submit string
19 Calendars []CalendarView
20
21 EventID int64
22 CalendarID int64
23 EventTitle string
24 Location string
25 Notes string
26 AllDay bool
27 StartLocal string
28 EndLocal string
29 TZ string
30 Kind string
31 Status string
32
33 Repeat string
34 RepeatEnd string
35
36 Error string
37 ErrorField string
38
39 // Series is set when editing something that recurs, so the form can
40 // ask which occurrences the edit applies to.
41 Series bool
42
43 // Booked is set when a booking references this event. The form
44 // then shows the scheduling controls disabled and carries their
45 // stored values as hidden inputs, so a title-only save submits
46 // exactly what is stored (booking spec B18, B25). BookedBy is the
47 // booker's name for the sentence beside them.
48 Booked bool
49 BookedBy string
50
51 // Occurrence is which one is being edited, when the edit arrived
52 // from an occurrence URL. Empty means the series itself.
53 Occurrence string
54}
55
56// readEventForm parses and validates a submission, returning the form
57// data to re-render on failure.
58func (h *handlers) readEventForm(r *http.Request, p Person) (EventInput, eventForm, bool) {
59 _ = r.ParseForm()
60 calID, _ := strconv.ParseInt(r.FormValue("calendar_id"), 10, 64)
61 allDay := r.FormValue("all_day") == "1"
62 start := formTime(r.FormValue("start"), allDay)
63 end := formTime(r.FormValue("end"), allDay)
64 // Turning a same-day timed event into an all-day event must preserve a
65 // non-empty half-open range. A date-only end on the same day is empty,
66 // so a non-midnight submitted end rounds *up* to the following day.
67 if allDay {
68 end = roundedAllDayEnd(start, end, r.FormValue("end"))
69 }
70 // The zone is the person's, except on a booked event's form, which
71 // carries the EVENT's own zone as a hidden field: after the host
72 // changes their profile zone, a title-only save must still match
73 // the stored row (B25).
74 tz := strings.TrimSpace(r.FormValue("tz"))
75 if tz == "" {
76 tz = p.Zone
77 }
78
79 in := EventInput{
80 CalendarID: calID,
81 Title: r.FormValue("title"),
82 Location: r.FormValue("location"),
83 Notes: r.FormValue("notes"),
84 AllDay: allDay,
85 StartLocal: start,
86 EndLocal: end,
87 TZ: tz,
88 Kind: r.FormValue("kind"),
89 Status: r.FormValue("status"),
90 RRule: RuleFromRepeat(r.FormValue("repeat"), r.FormValue("repeat_end"), start, tz, allDay),
91 }
92 f := eventForm{
93 CalendarID: calID,
94 EventTitle: in.Title,
95 Location: in.Location,
96 Notes: in.Notes,
97 AllDay: allDay,
98 StartLocal: in.StartLocal,
99 EndLocal: in.EndLocal,
100 TZ: tz,
101 Kind: in.Kind,
102 Status: in.Status,
103 Repeat: r.FormValue("repeat"),
104 RepeatEnd: r.FormValue("repeat_end"),
105 Occurrence: r.FormValue("occurrence"),
106 Series: r.FormValue("scope") != "",
107 }
108 if err := ValidateEventInput(&in); err != nil {
109 if ve, ok := Invalid(err); ok {
110 f.Error, f.ErrorField = ve.Message, ve.Field
111 } else {
112 f.Error = "That did not work."
113 }
114 return in, f, false
115 }
116 return in, f, true
117}
118
119func hasClock(raw string) bool {
120 v := strings.TrimSpace(raw)
121 if len(v) <= len(LayoutDate) || v[len(LayoutDate)] != 'T' {
122 return false
123 }
124 return !strings.HasPrefix(v[len(LayoutDate)+1:], "00:00")
125}
126
127func roundedAllDayEnd(start, end, raw string) string {
128 if !hasClock(raw) || end != start {
129 return end
130 }
131 d, err := time.Parse(LayoutDate, end)
132 if err != nil {
133 return end
134 }
135 return d.AddDate(0, 0, 1).Format(LayoutDate)
136}
137
138// formTime reads a Starts/Ends value in the shape the All day box
139// asks for. The form renders date inputs for an all-day event and
140// datetime inputs otherwise, but without script ticking or unticking
141// All day cannot change the input under it — so an all-day submission
142// may arrive as "2026-09-23T00:00" and a timed one as "2026-09-23".
143// Both mean something unambiguous: the date part, or midnight on it.
144// Anything else is passed through for validation to refuse.
145func formTime(raw string, allDay bool) string {
146 v := strings.TrimSpace(raw)
147 switch {
148 case allDay && len(v) > len(LayoutDate) && v[len(LayoutDate)] == 'T':
149 return v[:len(LayoutDate)]
150 case !allDay && len(v) == len(LayoutDate):
151 return v + "T00:00"
152 }
153 return v
154}
155
156// writable is the calendars somebody may actually create an event on.
157// A dropdown offering a calendar the server would refuse is a dropdown
158// that teaches people the app is broken.
159func writable(cals []CalendarView) []CalendarView {
160 out := make([]CalendarView, 0, len(cals))
161 for _, c := range cals {
162 if CanWrite(c.Role) {
163 out = append(out, c)
164 }
165 }
166 return out
167}
168