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 "strconv"
6 "strings"
7 "time"
8)
9
10// span is the day, or the RUN of days, that one sentence named.
11//
12// last is INCLUSIVE and equals start unless a range was read. "london
13// tues–thurs" is three days in London: read as a single date it becomes
14// a Tuesday with the word "thurs" stranded in the title, which is what
15// it did before this file existed.
16type span struct {
17 start time.Time
18 last time.Time
19 found bool
20 isRange bool
21}
22
23func oneDay(t time.Time, found bool) span {
24 return span{start: t, last: t, found: found}
25}
26
27// rangeSep is what people actually type between two dates. The hyphen
28// family has three members because a keyboard gives you "-" and macOS
29// substitutes "–" behind your back mid-sentence — the screenshot that
30// prompted this had an en dash nobody typed.
31const rangeSep = `(?:\s*[-–—]\s*|\s+(?:to|until|till|through|thru)\s+)`
32
33var (
34 // A run of weekdays: "tues–thurs", "Monday to Wednesday".
35 reWeekdayRange = regexp.MustCompile(`(?i)\b(?:from\s+)?(?:(next|this)\s+)?(?:on\s+)?(` +
36 weekdayAlt + `)` + rangeSep + `(?:next\s+)?(` + weekdayAlt + `)\b`)
37 // Two full dates: "12 March to 14 April".
38 reDateRange = regexp.MustCompile(`(?i)\b(?:from\s+)?(?:on\s+)?(\d{1,2})(?:st|nd|rd|th)?\s+(` +
39 monthAlt + `)` + rangeSep + `(\d{1,2})(?:st|nd|rd|th)?\s+(` + monthAlt + `)\b`)
40 // Days sharing a month: "12–14 March".
41 reDayDayMonth = regexp.MustCompile(`(?i)\b(?:from\s+)?(?:on\s+)?(\d{1,2})(?:st|nd|rd|th)?` +
42 rangeSep + `(\d{1,2})(?:st|nd|rd|th)?\s+(` + monthAlt + `)\b`)
43 // The same, month first: "March 12–14".
44 reMonthDayDay = regexp.MustCompile(`(?i)\b(?:from\s+)?(?:on\s+)?(` + monthAlt +
45 `)\s+(\d{1,2})(?:st|nd|rd|th)?` + rangeSep + `(\d{1,2})(?:st|nd|rd|th)?\b`)
46 // Numeric: "12/3-14/3".
47 reNumericRange = regexp.MustCompile(`\b(?:from\s+)?(?:on\s+)?(\d{1,2})/(\d{1,2})` +
48 rangeSep + `(\d{1,2})/(\d{1,2})\b`)
49)
50
51// takeSpan reads the day or the run of days out of the sentence.
52//
53// Every RANGE pattern is tried before any single-date one, and that
54// ordering is the whole fix. Run the singles first and "12–14 March"
55// matches "14 March" — the range's own end date — leaving a "12" behind
56// in the title and booking one day instead of three.
57//
58// Ranges are tried before takeTimes for the same reason a single date
59// is: the patterns here all require a month name, a weekday name or a
60// slash, so "Retro 3-4pm" cannot reach them. That is deliberate. A
61// digits-only range with no calendar word in it is a time, always.
62func takeSpan(c *consumed, now time.Time) span {
63 if m := c.take(reDateRange); m != nil {
64 d1, _ := strconv.Atoi(m[1])
65 d2, _ := strconv.Atoi(m[3])
66 s := onOrAfter(now, months[strings.ToLower(m[2])], d1)
67 e := time.Date(s.Year(), months[strings.ToLower(m[4])], d2, 0, 0, 0, 0, now.Location())
68 return rangeSpan(s, e, 1, 0)
69 }
70 if m := c.take(reDayDayMonth); m != nil {
71 d1, _ := strconv.Atoi(m[1])
72 d2, _ := strconv.Atoi(m[2])
73 mo := months[strings.ToLower(m[3])]
74 s := onOrAfter(now, mo, d1)
75 // A month rolls rather than a year: "30–2 April" is the 30th of
76 // March only if somebody wrote it that way, but "30–2 May" read
77 // as a year later would be absurd.
78 return rangeSpan(s, time.Date(s.Year(), mo, d2, 0, 0, 0, 0, now.Location()), 0, 1)
79 }
80 if m := c.take(reMonthDayDay); m != nil {
81 d1, _ := strconv.Atoi(m[2])
82 d2, _ := strconv.Atoi(m[3])
83 mo := months[strings.ToLower(m[1])]
84 s := onOrAfter(now, mo, d1)
85 return rangeSpan(s, time.Date(s.Year(), mo, d2, 0, 0, 0, 0, now.Location()), 0, 1)
86 }
87 if m := c.take(reNumericRange); m != nil {
88 return rangeSpan(numericDay(now, m[1], m[2]), numericDay(now, m[3], m[4]), 1, 0)
89 }
90 if m := c.take(reWeekdayRange); m != nil {
91 s := nextWeekday(now, weekdays[strings.ToLower(m[2])], strings.EqualFold(m[1], "next"))
92 // The end is the FIRST of that weekday on or after the start, so
93 // "Sunday to Tuesday" crosses the week boundary instead of
94 // running backwards into a negative event.
95 delta := (int(weekdays[strings.ToLower(m[3])]) - int(s.Weekday()) + 7) % 7
96 return span{start: s, last: s.AddDate(0, 0, delta), found: true, isRange: true}
97 }
98 day, found := takeDate(c, now)
99 return oneDay(day, found)
100}
101
102// rangeSpan makes an end that is not before its start, rolling forward
103// by years or months as the caller's units say.
104func rangeSpan(s, e time.Time, years, months int) span {
105 if e.Before(s) {
106 e = e.AddDate(years, months, 0)
107 }
108 return span{start: s, last: e, found: true, isRange: true}
109}
110
111func numericDay(now time.Time, dd, mm string) time.Time {
112 d, _ := strconv.Atoi(dd)
113 mo, _ := strconv.Atoi(mm)
114 return time.Date(now.Year(), time.Month(mo), d, 0, 0, 0, 0, now.Location())
115}
116