| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 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. |
| 15 | type 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. |
| 26 | func emptyPane() QuickPaneView { return QuickPaneView{Empty: true} } |
| 27 | |
| 28 | // quickPaneFor formats a parse for the pane. |
| 29 | func 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. |
| 47 | func 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. |
| 84 | func 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. |
| 99 | func (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. |
| 115 | func (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. |
| 182 | func 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. |
| 191 | func 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 | |