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 "fmt"
5 "regexp"
6 "strconv"
7 "strings"
8 "time"
9 "unicode"
10)
11
12// The availability sentence: "mondays between 12–3, tuesdays 12–2, 5-7".
13//
14// This is the ONE parser for it (Ruling B7). It shares the scanner and
15// the day names with quick add, and deliberately NOT quick add's clock
16// reading: clock() and applyMeridiem() forgive — they clamp 25 to 23
17// and read 13pm as 13 — which is right for a sentence someone is about
18// to see rendered and wrong for a rule that quietly decides when
19// strangers may book a month of Mondays. Here every raw component is
20// checked first and nothing is clamped (spec §3.1).
21
22var (
23 // Clauses split on commas and semicolons.
24 reAvailSplit = regexp.MustCompile(`\s*[,;]\s*`)
25
26 // Day parts, all anchored: a clause is a day part THEN its ranges.
27 reAvailGroup = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(weekdays|weekends|every\s+day|daily|all\s+days)\b`)
28 reAvailDayRange = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(` + weekdayAlt + `)s?` + rangeSep + `(` + weekdayAlt + `)s?\b`)
29 reAvailDay = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(` + weekdayAlt + `)s?\b`)
30
31 // A time range with RAW components. Two digits at most, minutes
32 // optional, a meridiem optional on either end; the validation is
33 // readClock's, not the pattern's, so "25-26" is matched here and
34 // refused there with the range quoted — rather than left over as a
35 // token nobody claimed, which would be true but unhelpful.
36 reAvailRange = regexp.MustCompile(`(?i)\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:[-–—]|to|until|till)\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\b`)
37
38 // Words that only join: dropped after the ranges are read.
39 reAvailFiller = regexp.MustCompile(`(?i)\b(between|from|and|on|at)\b`)
40)
41
42// ParseAvailability reads the sentence into weekly windows.
43//
44// Pure: the same text always gives the same windows and the same
45// problems. Windows are merged where they overlap or touch and sorted
46// Sunday-first then by start. Problems are every part of the text that
47// could not be read, each quoted, ALL of them — the pane shows the lot
48// so the host fixes the sentence once, and the page cannot be saved
49// while any remain (§3.2). What could be read is returned alongside.
50func ParseAvailability(text string) (windows []Window, problems []string) {
51 if strings.TrimSpace(text) == "" {
52 return nil, nil
53 }
54 var days []time.Weekday // the previous clause's, for inheritance
55 for _, clause := range reAvailSplit.Split(text, -1) {
56 clause = strings.TrimSpace(clause)
57 if clause == "" {
58 continue
59 }
60 c := newConsumed(clause)
61
62 named, found := takeDays(c)
63 switch {
64 case found:
65 days = named
66 case days == nil:
67 problems = append(problems, fmt.Sprintf("Could not read %q: no day named", clause))
68 }
69
70 ranges, bad := takeRanges(c)
71 problems = append(problems, bad...)
72
73 for c.take(reAvailFiller) != nil {
74 }
75 left := leftover(c)
76 problems = append(problems, left...)
77
78 if len(ranges) == 0 && len(bad) == 0 && len(left) == 0 && found {
79 problems = append(problems, fmt.Sprintf("Could not read %q: no time given", clause))
80 }
81 if !found && days == nil {
82 continue
83 }
84 for _, d := range days {
85 for _, r := range ranges {
86 windows = append(windows, Window{Weekday: d, StartMin: r[0], EndMin: r[1]})
87 }
88 }
89 }
90 return MergeWindows(windows), problems
91}
92
93// takeDays reads the day part off the front of a clause.
94func takeDays(c *consumed) ([]time.Weekday, bool) {
95 if m := c.take(reAvailGroup); m != nil {
96 switch strings.ToLower(spaces.ReplaceAllString(m[1], " ")) {
97 case "weekdays":
98 return []time.Weekday{time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday}, true
99 case "weekends":
100 return []time.Weekday{time.Saturday, time.Sunday}, true
101 default:
102 return []time.Weekday{time.Sunday, time.Monday, time.Tuesday, time.Wednesday,
103 time.Thursday, time.Friday, time.Saturday}, true
104 }
105 }
106 if m := c.take(reAvailDayRange); m != nil {
107 // Forward modulo 7, so "fri-mon" is four days across the weekend
108 // and never a backwards run.
109 from, to := weekdays[strings.ToLower(m[1])], weekdays[strings.ToLower(m[2])]
110 out := []time.Weekday{from}
111 for d := from; d != to; {
112 d = (d + 1) % 7
113 out = append(out, d)
114 }
115 return out, true
116 }
117 if m := c.take(reAvailDay); m != nil {
118 return []time.Weekday{weekdays[strings.ToLower(m[1])]}, true
119 }
120 return nil, false
121}
122
123// takeRanges reads every time range in the clause. Each good one is a
124// [start, end) pair of minutes; each bad one is a problem quoting it.
125func takeRanges(c *consumed) (ranges [][2]int, problems []string) {
126 for {
127 m := c.take(reAvailRange)
128 if m == nil {
129 return ranges, problems
130 }
131 start, end, ok := readRange(m)
132 if !ok {
133 problems = append(problems, fmt.Sprintf("Could not read %q", strings.TrimSpace(m[0])))
134 continue
135 }
136 ranges = append(ranges, [2]int{start, end})
137 }
138}
139
140// readRange resolves one matched range. The meridiem rule (§3.1): an
141// explicit am/pm on either end applies to both unless the other says
142// otherwise; with none on either end the working-day guess applies.
143func readRange(m []string) (start, end int, ok bool) {
144 sm, em := strings.ToLower(m[3]), strings.ToLower(m[6])
145 inferPM := sm == "" && em == ""
146 start, ok = readClock(m[1], m[2], sm, inferPM, false)
147 if !ok {
148 return 0, 0, false
149 }
150 end, ok = readClock(m[4], m[5], em, inferPM, true)
151 if !ok {
152 return 0, 0, false
153 }
154 // Inheritance is applied AFTER each end is validated as written:
155 // "0-1am" is midnight to one, and the 0 was typed without a
156 // meridiem, so it is checked as a 24-hour figure and only then
157 // told it is in the morning.
158 if sm == "" && em != "" {
159 start = inheritMeridiem(start, em)
160 }
161 if em == "" && sm != "" {
162 end = inheritMeridiem(end, sm)
163 }
164 if end <= start {
165 return 0, 0, false
166 }
167 return start, end, true
168}
169
170// readClock validates RAW components; nothing is clamped.
171//
172// meridiem != "": hour 1..12, minute 0..59; 12am → 0, 12pm → 12.
173// meridiem == "": hour 0..24, minute 0..59; 24 only as 24:00 and only
174// when atEnd; with inferPM, an hour of 1..7 is the
175// afternoon — the HOUR COMPONENT, whatever the minutes,
176// so 12:30-2:30 is 12:30–14:30 and 7:45 is 19:45.
177func readClock(hh, mm, meridiem string, inferPM, atEnd bool) (minutes int, ok bool) {
178 h, err := strconv.Atoi(hh)
179 if err != nil {
180 return 0, false
181 }
182 m := 0
183 if mm != "" {
184 if m, err = strconv.Atoi(mm); err != nil || m > 59 {
185 return 0, false
186 }
187 }
188 switch meridiem {
189 case "am", "pm":
190 if h < 1 || h > 12 {
191 return 0, false
192 }
193 if h == 12 {
194 h = 0
195 }
196 if meridiem == "pm" {
197 h += 12
198 }
199 default:
200 if h > 24 || (h == 24 && (m != 0 || !atEnd)) {
201 return 0, false
202 }
203 if inferPM && h >= 1 && h <= 7 {
204 h += 12
205 }
206 }
207 return h*60 + m, true
208}
209
210// inheritMeridiem applies the other end's am/pm to a clock that was
211// written without one and has already passed the 24-hour check. Only
212// the twelve ambiguous hours move: 0 stays midnight and 13–24 are
213// already unambiguous, so "13-5pm" is 13:00–17:00 as written.
214func inheritMeridiem(minutes int, meridiem string) int {
215 h := minutes / 60
216 switch {
217 case meridiem == "pm" && h >= 1 && h <= 11:
218 return minutes + 12*60
219 case meridiem == "am" && h == 12:
220 return minutes - 12*60
221 }
222 return minutes
223}
224
225// leftover is every run of text nobody claimed, each a problem quoting
226// exactly that run. A run with no letter or digit in it — a stray full
227// stop — is not a token and is let go.
228func leftover(c *consumed) []string {
229 var problems []string
230 var run strings.Builder
231 flush := func() {
232 s := strings.TrimSpace(run.String())
233 run.Reset()
234 if strings.IndexFunc(s, isWordRune) >= 0 {
235 problems = append(problems, fmt.Sprintf("Could not read %q", s))
236 }
237 }
238 for _, r := range c.work {
239 if r == 0 {
240 flush()
241 continue
242 }
243 run.WriteRune(r)
244 }
245 flush()
246 return problems
247}
248
249func isWordRune(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) }
250