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 "regexp"
5 "strings"
6 "time"
7)
8
9// QuickParse is everything one typed sentence was understood to mean.
10//
11// It is a plain value, and ParseQuick is a pure function of (text, now,
12// zone). That is deliberate: this is the one piece of the app whose
13// correctness is entirely a question of "what does this sentence mean",
14// so it is testable as a table of sentences and nothing else has to be
15// stood up to ask it a question.
16type QuickParse struct {
17 Title string
18 Location string
19
20 // StartLocal and EndLocal are wall clock in the person's own zone,
21 // "2006-01-02T15:04" — the same form the create form submits, and
22 // the record the app stores (Ruling 1).
23 StartLocal string
24 EndLocal string
25 AllDay bool
26
27 // Repeat is one of the picker's own six values, never a rule the
28 // picker cannot offer (Ruling 4).
29 Repeat string
30
31 // Attendees are addresses found in the text. They are RECORDED, not
32 // invited: nothing is emailed to them. See Notes.
33 Attendees []string
34
35 // Notes are the sentences the pane shows about what was assumed.
36 // Every defaulted field names itself here, because a default the
37 // person cannot see is a guess.
38 Notes []string
39}
40
41// Empty reports whether nothing usable was typed.
42func (q QuickParse) Empty() bool {
43 return strings.TrimSpace(q.Title) == "" && len(q.Attendees) == 0
44}
45
46// consumed marks the parts of the sentence that have already been
47// understood.
48//
49// A parser that deleted matches as it went would lose the positions
50// that later patterns need — "at" means one thing before a time has
51// been taken out of the sentence and another thing after. Overwriting
52// with NUL instead keeps every index stable AND stops a later regex
53// matching across a hole, which is what makes "at 3pm in McCloskeys"
54// resolve to a time and a place rather than to two places.
55type consumed struct {
56 src []rune
57 work []rune
58}
59
60func newConsumed(s string) *consumed {
61 r := []rune(s)
62 w := make([]rune, len(r))
63 copy(w, r)
64 return &consumed{src: r, work: w}
65}
66
67func (c *consumed) String() string { return string(c.work) }
68
69// take finds the first match of re in what is left and blanks it,
70// returning the submatches. group is the capture whose span survives —
71// pass 0 to consume the whole match.
72func (c *consumed) take(re *regexp.Regexp) []string {
73 loc := re.FindStringSubmatchIndex(string(c.work))
74 if loc == nil {
75 return nil
76 }
77 // Indices from FindStringSubmatchIndex are byte offsets into the
78 // string form; convert once to rune offsets so the blanking lines
79 // up on a sentence containing anything non-ASCII.
80 s := string(c.work)
81 from := len([]rune(s[:loc[0]]))
82 to := len([]rune(s[:loc[1]]))
83 for i := from; i < to; i++ {
84 c.work[i] = 0
85 }
86 return matchStrings(s, loc)
87}
88
89func matchStrings(s string, loc []int) []string {
90 out := make([]string, len(loc)/2)
91 for i := range out {
92 if loc[2*i] < 0 {
93 out[i] = ""
94 continue
95 }
96 out[i] = s[loc[2*i]:loc[2*i+1]]
97 }
98 return out
99}
100
101// rest is what nobody claimed: the title.
102func (c *consumed) rest() string {
103 var b strings.Builder
104 for _, r := range c.work {
105 if r == 0 {
106 b.WriteRune(' ')
107 continue
108 }
109 b.WriteRune(r)
110 }
111 return tidy(b.String())
112}
113
114var spaces = regexp.MustCompile(`\s+`)
115
116// tidy collapses the whitespace that blanking leaves behind and drops
117// the joining words that only made sense next to what was removed.
118func tidy(s string) string {
119 s = spaces.ReplaceAllString(s, " ")
120 s = strings.TrimSpace(s)
121 // Leading and trailing connectives, e.g. "Coffee with" once the
122 // address has been lifted out of "Coffee with doc@example.com".
123 for {
124 before := s
125 for _, w := range []string{"with", "at", "in", "on", "from", "to", "and", "-", "–", ","} {
126 s = strings.TrimSpace(strings.TrimSuffix(s, " "+w))
127 if strings.EqualFold(s, w) {
128 s = ""
129 }
130 s = strings.TrimSpace(strings.TrimPrefix(s, w+" "))
131 }
132 s = strings.Trim(s, " ,-–")
133 if s == before {
134 return s
135 }
136 }
137}
138
139// ParseQuick reads one typed sentence into an event.
140//
141// The ORDER of these steps is the design, not an implementation detail.
142// Addresses go first because an address contains characters every later
143// pattern would misread. Times are taken before places because "at" is
144// ambiguous and time has to win it: read places first and "at 3pm in
145// McCloskeys" books a meeting in a place called "3pm".
146func ParseQuick(text string, now time.Time, zone string) QuickParse {
147 var q QuickParse
148 c := newConsumed(text)
149
150 q.Attendees = takeAttendees(c)
151 q.AllDay = takeAllDay(c)
152 q.Repeat = takeRepeat(c)
153
154 when := takeSpan(c, now)
155 start, end, timeFound := takeTimes(c)
156 dur, durFound := takeDuration(c)
157
158 q.Location = takeLocation(c)
159 q.Title = c.rest()
160
161 q.fill(now, zone, when, start, end, timeFound, dur, durFound)
162 return q
163}
164
165// fill turns what was found into a start and an end, and writes a note
166// for every part it had to decide on its own.
167func (q *QuickParse) fill(now time.Time, zone string, when span,
168 start, end clockTime, timeFound bool, dur time.Duration, durFound bool) {
169
170 day, last := when.start, when.last
171 if !when.found {
172 day, last = now, now
173 }
174
175 // A RUN of days with no clock anywhere in the sentence is an
176 // all-day event. "london tues-thurs" is a trip, and reading it as an
177 // hour on Tuesday with a two-day tail nobody asked for would be
178 // wrong in a way the pane could not show.
179 if when.isRange && !timeFound && !q.AllDay {
180 q.AllDay = true
181 q.Notes = append(q.Notes,
182 "A run of days with no time, so this is an all-day event.")
183 }
184
185 if q.AllDay {
186 // A DATE, never a datetime at midnight - that is what puts a
187 // birthday on the wrong day west of Greenwich. And the end is
188 // EXCLUSIVE like every other range in this app: the day AFTER
189 // the last one. Writing the same date twice makes an all-day
190 // event of zero length, which the validator refuses outright.
191 q.StartLocal = day.Format(LayoutDate)
192 q.EndLocal = last.AddDate(0, 0, 1).Format(LayoutDate)
193 q.note(when.found, len(q.Attendees) > 0)
194 return
195 }
196
197 if !timeFound {
198 // The next half hour, said out loud rather than assumed.
199 next := nextHalfHour(now)
200 if !when.found {
201 day, last = next, next
202 }
203 start = clockTime{h: next.Hour(), m: next.Minute()}
204 q.Notes = append(q.Notes,
205 "No time found, so this starts at the next half hour.")
206 }
207
208 startAt := time.Date(day.Year(), day.Month(), day.Day(), start.h, start.m, 0, 0, time.UTC)
209
210 // Every end lands on the LAST day of the span, which is the same day
211 // as the start unless a range was read - so there is one rule here
212 // rather than a range case bolted onto a single-day one.
213 var endAt time.Time
214 switch {
215 case end.set:
216 endAt = time.Date(last.Year(), last.Month(), last.Day(), end.h, end.m, 0, 0, time.UTC)
217 // "9pm to 1am" crosses midnight; an end before its start is the
218 // next day rather than a negative meeting.
219 if !endAt.After(startAt) {
220 endAt = endAt.AddDate(0, 0, 1)
221 }
222 case durFound:
223 endAt = time.Date(last.Year(), last.Month(), last.Day(), start.h, start.m, 0, 0, time.UTC).Add(dur)
224 default:
225 endAt = time.Date(last.Year(), last.Month(), last.Day(), start.h, start.m, 0, 0, time.UTC).Add(time.Hour)
226 if when.isRange {
227 q.Notes = append(q.Notes,
228 "No end time found, so this ends an hour after it starts on the last day.")
229 } else {
230 q.Notes = append(q.Notes, "No end time found, so this lasts an hour.")
231 }
232 }
233
234 q.StartLocal = startAt.Format(LayoutLocal)
235 q.EndLocal = endAt.Format(LayoutLocal)
236 q.note(when.found, len(q.Attendees) > 0)
237}
238
239// note adds the two sentences every branch above owes the pane. They
240// live here because the all-day branch returns early and used to skip
241// the attendee one - the single line a person most needs to read.
242func (q *QuickParse) note(dayFound, haveAttendees bool) {
243 if !dayFound {
244 q.Notes = append(q.Notes, "No date found, so this is today.")
245 }
246 if haveAttendees {
247 q.Notes = append(q.Notes,
248 "Nobody is emailed. Attendees are recorded on the event only.")
249 }
250}
251
252// nextHalfHour rounds forward to the next :00 or :30.
253func nextHalfHour(now time.Time) time.Time {
254 t := now.Truncate(time.Minute)
255 switch {
256 case t.Minute() < 30:
257 return t.Add(time.Duration(30-t.Minute()) * time.Minute)
258 default:
259 return t.Add(time.Duration(60-t.Minute()) * time.Minute)
260 }
261}
262