| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 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 | |
| 22 | var ( |
| 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. |
| 50 | func 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. |
| 94 | func 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. |
| 125 | func 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. |
| 143 | func 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. |
| 177 | func 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. |
| 214 | func 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. |
| 228 | func 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 | |
| 249 | func isWordRune(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) } |
| 250 | |