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 "net/url"
6 "strconv"
7 "strings"
8 "time"
9)
10
11// QuickPaneView is the parse as the pane prints it: already formatted,
12// already in the viewer's zone, already turned into sentences. The
13// template does no arithmetic, exactly as chipFor leaves none for the
14// chip.
15type QuickPaneView struct {
16 Empty bool
17 Title string
18 When string
19 Location string
20 Repeat string
21 Attendees []string
22 Notes []string
23}
24
25// emptyPane is the first paint: the box open, nothing typed.
26func emptyPane() QuickPaneView { return QuickPaneView{Empty: true} }
27
28// quickPaneFor formats a parse for the pane.
29func quickPaneFor(q QuickParse, zone string) QuickPaneView {
30 if q.Empty() && q.StartLocal == "" {
31 return emptyPane()
32 }
33 v := QuickPaneView{
34 Title: q.Title,
35 Location: q.Location,
36 Attendees: q.Attendees,
37 Notes: q.Notes,
38 When: quickWhen(q, zone),
39 }
40 if q.Repeat != "" && q.Repeat != RepeatNone {
41 v.Repeat = repeatLabel(q.Repeat, q.StartLocal, zone, q.AllDay)
42 }
43 return v
44}
45
46// quickWhen writes the times out the way the pane says them.
47func quickWhen(q QuickParse, zone string) string {
48 loc, err := Zone(zone)
49 if err != nil {
50 return ""
51 }
52 start, _, err := ParseLocal(q.StartLocal, loc)
53 if err != nil {
54 return ""
55 }
56 day := start.Format("Monday 2 January")
57 if q.AllDay {
58 // EndLocal is exclusive, so the last day a person is actually
59 // busy is the day before it. Printing the stored end would tell
60 // somebody their trip runs a day longer than it does.
61 if end, _, err := ParseLocal(q.EndLocal, loc); err == nil {
62 if last := end.AddDate(0, 0, -1); last.After(start) {
63 return day + " – " + last.Format("Monday 2 January") + ", all day"
64 }
65 }
66 return day + ", all day"
67 }
68 end, _, err := ParseLocal(q.EndLocal, loc)
69 if err != nil {
70 return day + ", " + start.Format("15:04")
71 }
72 if end.Format(LayoutDate) != start.Format(LayoutDate) {
73 // A meeting that crosses midnight says both days rather than
74 // printing "21:00–01:00" and leaving somebody to work out which
75 // 01:00 is meant.
76 return day + ", " + start.Format("15:04") + " – " +
77 end.Format("Monday 2 January") + ", " + end.Format("15:04")
78 }
79 return day + ", " + start.Format("15:04") + "–" + end.Format("15:04")
80}
81
82// repeatLabel reuses the picker's own labels, so the pane and the form
83// describe one rule the same way.
84func repeatLabel(repeat, startLocal, zone string, allDay bool) string {
85 for _, c := range RepeatChoicesFor(startLocal, zone, allDay) {
86 if c.Value == repeat {
87 return c.Label
88 }
89 }
90 return ""
91}
92
93// quickParse answers the live pane: parse what has been typed so far
94// and return the pane as an HTML fragment.
95//
96// The parse happens HERE and only here. There is no JavaScript parser,
97// because two implementations of "next Tuesday at half three" is two
98// answers, and the one shown would not be the one saved.
99func (h *handlers) quickParse(w http.ResponseWriter, r *http.Request) {
100 p, _ := CurrentPerson(r)
101 if err := r.ParseForm(); err != nil {
102 renderFragment(w, "quickpane", emptyPane())
103 return
104 }
105 text := strings.TrimSpace(r.FormValue("text"))
106 if text == "" {
107 renderFragment(w, "quickpane", emptyPane())
108 return
109 }
110 zone := personZone(p)
111 renderFragment(w, "quickpane", quickPaneFor(ParseQuick(text, nowIn(zone), zone), zone))
112}
113
114// quickAdd creates the event one sentence describes.
115func (h *handlers) quickAdd(w http.ResponseWriter, r *http.Request) {
116 p, _ := CurrentPerson(r)
117 if err := r.ParseForm(); err != nil {
118 h.errorPage(w, r, http.StatusBadRequest, "")
119 return
120 }
121 text := strings.TrimSpace(r.FormValue("text"))
122 if text == "" {
123 http.Redirect(w, r, "/events/new", http.StatusSeeOther)
124 return
125 }
126 zone := personZone(p)
127 q := ParseQuick(text, nowIn(zone), zone)
128
129 calID, err := h.store.DefaultCalendarID(r.Context(), p.ID)
130 if err != nil || calID == 0 {
131 h.fail(w, r, "quick add calendar", err)
132 return
133 }
134
135 title := q.Title
136 if title == "" {
137 // Never a blank title: the sentence was all time and place, so
138 // the honest fallback is a name the person will recognise and
139 // can edit, not an empty row.
140 title = "Untitled"
141 }
142
143 in := EventInput{
144 CalendarID: calID,
145 Title: title,
146 Location: q.Location,
147 AllDay: q.AllDay,
148 StartLocal: q.StartLocal,
149 EndLocal: q.EndLocal,
150 TZ: zone,
151 Kind: "meeting",
152 Status: "confirmed",
153 RRule: RuleFromRepeat(q.Repeat, "", q.StartLocal, zone, q.AllDay),
154 }
155 e, err := h.store.CreateEvent(r.Context(), p.ID, in)
156 if err != nil {
157 if _, isVE := Invalid(err); isVE {
158 // The parse produced something the validator refuses. Send
159 // the sentence to the full form rather than dropping it:
160 // retyping it is the one outcome nobody would forgive.
161 http.Redirect(w, r, "/events/new?text="+url.QueryEscape(text), http.StatusSeeOther)
162 return
163 }
164 h.fail(w, r, "quick add", err)
165 return
166 }
167 if len(q.Attendees) > 0 {
168 if err := h.store.SetAttendees(r.Context(), e.ID, q.Attendees); err != nil {
169 h.logger.Error("calendar: quick add attendees", "stage", "quick.attendees",
170 "err", scrubErr(err))
171 }
172 }
173 if err := h.store.EnsureDefaultReminder(r.Context(), p, e); err != nil {
174 h.logger.Error("calendar: default reminder", "stage", "quick.reminder",
175 "err", scrubErr(err))
176 }
177 http.Redirect(w, r, "/events/"+strconv.FormatInt(e.ID, 10), http.StatusSeeOther)
178}
179
180// personZone is the viewer's zone, with the app default when they have
181// not set one.
182func personZone(p Person) string {
183 if p.Zone == "" {
184 return defaultZone
185 }
186 return p.Zone
187}
188
189// nowIn is the current wall clock in zone. The parser reads "tomorrow"
190// and "at 3" against the PERSON's today, never the server's.
191func nowIn(zone string) time.Time {
192 loc, err := Zone(zone)
193 if err != nil {
194 return time.Now().UTC()
195 }
196 return time.Now().In(loc)
197}
198