| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 4 | "regexp" |
| 5 | "strconv" |
| 6 | "strings" |
| 7 | "time" |
| 8 | ) |
| 9 | |
| 10 | // clockTime is a wall-clock hour and minute, with set distinguishing |
| 11 | // "midnight" from "not given". |
| 12 | type clockTime struct { |
| 13 | h, m int |
| 14 | set bool |
| 15 | } |
| 16 | |
| 17 | var ( |
| 18 | // A range: "3-4pm", "3pm to 4:30", "from 9 until 10:15". |
| 19 | reRange = regexp.MustCompile(`(?i)\b(?:from\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:-|–|—|to|until|till)\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\b`) |
| 20 | // A time with minutes: "15:00", "at 3:30pm". |
| 21 | reHHMM = regexp.MustCompile(`(?i)\b(?:at\s+)?(\d{1,2}):(\d{2})\s*(am|pm)?\b`) |
| 22 | // A time with a meridiem: "3pm", "at 11 am". |
| 23 | reMerid = regexp.MustCompile(`(?i)\b(?:at\s+)?(\d{1,2})\s*(am|pm)\b`) |
| 24 | // A bare hour, and ONLY after "at": "at 3". Without the "at" this |
| 25 | // would eat the 4 in "room 4". |
| 26 | reBareAt = regexp.MustCompile(`(?i)\bat\s+(\d{1,2})\b`) |
| 27 | ) |
| 28 | |
| 29 | // takeTimes reads a start and possibly an end out of the sentence. |
| 30 | func takeTimes(c *consumed) (start, end clockTime, found bool) { |
| 31 | if m := c.take(reRange); m != nil { |
| 32 | s := clock(m[1], m[2]) |
| 33 | e := clock(m[4], m[5]) |
| 34 | sm, em := strings.ToLower(m[3]), strings.ToLower(m[6]) |
| 35 | // "3-4pm" means both are pm: a meridiem on either end applies |
| 36 | // to the whole range unless the other says otherwise. |
| 37 | if sm == "" { |
| 38 | sm = em |
| 39 | } |
| 40 | if em == "" { |
| 41 | em = sm |
| 42 | } |
| 43 | s = applyMeridiem(s, sm) |
| 44 | e = applyMeridiem(e, em) |
| 45 | return s, e, true |
| 46 | } |
| 47 | if m := c.take(reHHMM); m != nil { |
| 48 | return applyMeridiem(clock(m[1], m[2]), strings.ToLower(m[3])), clockTime{}, true |
| 49 | } |
| 50 | if m := c.take(reMerid); m != nil { |
| 51 | return applyMeridiem(clock(m[1], ""), strings.ToLower(m[2])), clockTime{}, true |
| 52 | } |
| 53 | if m := c.take(reBareAt); m != nil { |
| 54 | return applyMeridiem(clock(m[1], ""), ""), clockTime{}, true |
| 55 | } |
| 56 | return clockTime{}, clockTime{}, false |
| 57 | } |
| 58 | |
| 59 | func clock(hh, mm string) clockTime { |
| 60 | h, _ := strconv.Atoi(hh) |
| 61 | m := 0 |
| 62 | if mm != "" { |
| 63 | m, _ = strconv.Atoi(mm) |
| 64 | } |
| 65 | return clockTime{h: h, m: m, set: true} |
| 66 | } |
| 67 | |
| 68 | // applyMeridiem resolves am/pm, and guesses when there is none. |
| 69 | // |
| 70 | // The guess is the working day: a bare 1 to 7 means the afternoon, |
| 71 | // because "at 3" is three in the afternoon to everybody who is not a |
| 72 | // baker. 8 to 12 are taken as written. This is a guess and the pane |
| 73 | // prints the resolved time, so a wrong one is visible before it is |
| 74 | // saved rather than after. |
| 75 | func applyMeridiem(t clockTime, meridiem string) clockTime { |
| 76 | if !t.set { |
| 77 | return t |
| 78 | } |
| 79 | switch meridiem { |
| 80 | case "pm": |
| 81 | if t.h < 12 { |
| 82 | t.h += 12 |
| 83 | } |
| 84 | case "am": |
| 85 | if t.h == 12 { |
| 86 | t.h = 0 |
| 87 | } |
| 88 | default: |
| 89 | if t.h >= 1 && t.h <= 7 { |
| 90 | t.h += 12 |
| 91 | } |
| 92 | } |
| 93 | if t.h > 23 { |
| 94 | t.h = 23 |
| 95 | } |
| 96 | return t |
| 97 | } |
| 98 | |
| 99 | var ( |
| 100 | reDurNum = regexp.MustCompile(`(?i)\bfor\s+(\d+)\s*(minutes?|mins?|hours?|hrs?|h|m)\b`) |
| 101 | reDurWord = regexp.MustCompile(`(?i)\bfor\s+(half\s+an?|an?)\s+(hour|hours)\b`) |
| 102 | ) |
| 103 | |
| 104 | // takeDuration reads "for 90 minutes", "for 2h", "for an hour". |
| 105 | func takeDuration(c *consumed) (time.Duration, bool) { |
| 106 | if m := c.take(reDurNum); m != nil { |
| 107 | n, _ := strconv.Atoi(m[1]) |
| 108 | unit := strings.ToLower(m[2]) |
| 109 | if strings.HasPrefix(unit, "h") { |
| 110 | return time.Duration(n) * time.Hour, true |
| 111 | } |
| 112 | return time.Duration(n) * time.Minute, true |
| 113 | } |
| 114 | if m := c.take(reDurWord); m != nil { |
| 115 | if strings.HasPrefix(strings.ToLower(m[1]), "half") { |
| 116 | return 30 * time.Minute, true |
| 117 | } |
| 118 | return time.Hour, true |
| 119 | } |
| 120 | return 0, false |
| 121 | } |
| 122 | |
| 123 | // weekdayAlt and monthAlt are shared with quickadd_range.go. One |
| 124 | // spelling of "the names of the days", so a range and a single date can |
| 125 | // never disagree about whether "tues" is a word. |
| 126 | const ( |
| 127 | weekdayAlt = `monday|tuesday|wednesday|thursday|friday|saturday|sunday|mon|tue|tues|wed|weds|thu|thur|thurs|fri|sat|sun` |
| 128 | monthAlt = `january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec` |
| 129 | ) |
| 130 | |
| 131 | var ( |
| 132 | reToday = regexp.MustCompile(`(?i)\b(today|tonight)\b`) |
| 133 | reTomorrow = regexp.MustCompile(`(?i)\btomorrow\b`) |
| 134 | reWeekday = regexp.MustCompile(`(?i)\b(?:(next|this)\s+)?(?:on\s+)?(` + weekdayAlt + `)\b`) |
| 135 | reDayMonth = regexp.MustCompile(`(?i)\b(?:on\s+)?(\d{1,2})(?:st|nd|rd|th)?\s+(` + monthAlt + `)\b`) |
| 136 | reMonthDay = regexp.MustCompile(`(?i)\b(?:on\s+)?(` + monthAlt + `)\s+(\d{1,2})(?:st|nd|rd|th)?\b`) |
| 137 | reNumeric = regexp.MustCompile(`\b(?:on\s+)?(\d{1,2})/(\d{1,2})(?:/(\d{2,4}))?\b`) |
| 138 | ) |
| 139 | |
| 140 | var weekdays = map[string]time.Weekday{ |
| 141 | "monday": time.Monday, "mon": time.Monday, |
| 142 | "tuesday": time.Tuesday, "tue": time.Tuesday, "tues": time.Tuesday, |
| 143 | "wednesday": time.Wednesday, "wed": time.Wednesday, "weds": time.Wednesday, |
| 144 | "thursday": time.Thursday, "thu": time.Thursday, "thur": time.Thursday, "thurs": time.Thursday, |
| 145 | "friday": time.Friday, "fri": time.Friday, |
| 146 | "saturday": time.Saturday, "sat": time.Saturday, |
| 147 | "sunday": time.Sunday, "sun": time.Sunday, |
| 148 | } |
| 149 | |
| 150 | var months = map[string]time.Month{ |
| 151 | "january": time.January, "jan": time.January, |
| 152 | "february": time.February, "feb": time.February, |
| 153 | "march": time.March, "mar": time.March, |
| 154 | "april": time.April, "apr": time.April, |
| 155 | "may": time.May, |
| 156 | "june": time.June, "jun": time.June, |
| 157 | "july": time.July, "jul": time.July, |
| 158 | "august": time.August, "aug": time.August, |
| 159 | "september": time.September, "sep": time.September, "sept": time.September, |
| 160 | "october": time.October, "oct": time.October, |
| 161 | "november": time.November, "nov": time.November, |
| 162 | "december": time.December, "dec": time.December, |
| 163 | } |
| 164 | |
| 165 | // takeDate reads the day the event is on, relative to now. |
| 166 | // |
| 167 | // Ordered most specific first. The numeric form is last because |
| 168 | // "12/3" would otherwise be read out of a time range. |
| 169 | func takeDate(c *consumed, now time.Time) (time.Time, bool) { |
| 170 | if c.take(reToday) != nil { |
| 171 | return now, true |
| 172 | } |
| 173 | if c.take(reTomorrow) != nil { |
| 174 | return now.AddDate(0, 0, 1), true |
| 175 | } |
| 176 | if m := c.take(reDayMonth); m != nil { |
| 177 | d, _ := strconv.Atoi(m[1]) |
| 178 | return onOrAfter(now, months[strings.ToLower(m[2])], d), true |
| 179 | } |
| 180 | if m := c.take(reMonthDay); m != nil { |
| 181 | d, _ := strconv.Atoi(m[2]) |
| 182 | return onOrAfter(now, months[strings.ToLower(m[1])], d), true |
| 183 | } |
| 184 | if m := c.take(reWeekday); m != nil { |
| 185 | return nextWeekday(now, weekdays[strings.ToLower(m[2])], strings.EqualFold(m[1], "next")), true |
| 186 | } |
| 187 | if m := c.take(reNumeric); m != nil { |
| 188 | d, _ := strconv.Atoi(m[1]) |
| 189 | mo, _ := strconv.Atoi(m[2]) |
| 190 | year := now.Year() |
| 191 | if m[3] != "" { |
| 192 | year, _ = strconv.Atoi(m[3]) |
| 193 | if year < 100 { |
| 194 | year += 2000 |
| 195 | } |
| 196 | } |
| 197 | return time.Date(year, time.Month(mo), d, 0, 0, 0, 0, now.Location()), true |
| 198 | } |
| 199 | return now, false |
| 200 | } |
| 201 | |
| 202 | // nextWeekday is the coming one. "next Tuesday" said on a Tuesday means |
| 203 | // the one after this, never today — which is what everybody means and |
| 204 | // what a plain modulo gets wrong. |
| 205 | func nextWeekday(now time.Time, want time.Weekday, explicitNext bool) time.Time { |
| 206 | delta := (int(want) - int(now.Weekday()) + 7) % 7 |
| 207 | if delta == 0 { |
| 208 | delta = 7 |
| 209 | } |
| 210 | if explicitNext && delta < 7 { |
| 211 | delta += 7 |
| 212 | } |
| 213 | return now.AddDate(0, 0, delta) |
| 214 | } |
| 215 | |
| 216 | // onOrAfter picks the next occurrence of a day and month, rolling into |
| 217 | // next year rather than booking a date that has already gone. |
| 218 | func onOrAfter(now time.Time, month time.Month, day int) time.Time { |
| 219 | t := time.Date(now.Year(), month, day, 0, 0, 0, 0, now.Location()) |
| 220 | if t.Before(now.AddDate(0, 0, -1)) { |
| 221 | t = t.AddDate(1, 0, 0) |
| 222 | } |
| 223 | return t |
| 224 | } |
| 225 | |