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)
7
8// reAddress is deliberately the same shape as logsafe.go's redactor.
9// One notion of "looks like an address" for both finding and hiding one.
10var reAddress = regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`)
11
12// takeAttendees lifts every address out of the sentence.
13//
14// FIRST, before any other pattern runs. An address is full of
15// characters the other patterns care about — digits, dots, and a "." in
16// a domain reads as a date separator — so leaving one in the sentence
17// gives the date parser something that looks like 12.3 to find.
18func takeAttendees(c *consumed) []string {
19 var out []string
20 seen := map[string]bool{}
21 for {
22 m := c.take(reAddress)
23 if m == nil {
24 return out
25 }
26 addr := normalizeAddress(m[0])
27 if seen[addr] {
28 continue
29 }
30 seen[addr] = true
31 out = append(out, addr)
32 }
33}
34
35var reAllDay = regexp.MustCompile(`(?i)\b(all[\s-]?day)\b`)
36
37func takeAllDay(c *consumed) bool { return c.take(reAllDay) != nil }
38
39var (
40 reEveryWeekday = regexp.MustCompile(`(?i)\bevery\s+(` + weekdayAlt + `)\b`)
41 reEveryUnit = regexp.MustCompile(`(?i)\bevery\s+(day|weekday|week|fortnight|two\s+weeks|month|year)\b`)
42 reDailyWord = regexp.MustCompile(`(?i)\b(daily|weekly|fortnightly|monthly|yearly|annually)\b`)
43)
44
45// takeRepeat maps a repeating phrase onto one of the picker's own six
46// choices, and NOTHING ELSE.
47//
48// Ruling 4 stands here: the subset is what a picker offers. "every
49// second Tuesday of the month" has no choice to map to, so it is not
50// taken — it stays in the sentence, lands in the title, and the pane
51// shows it sitting there unhandled. Inventing an RRULE the picker
52// cannot re-display would make an event nobody can edit.
53func takeRepeat(c *consumed) string {
54 if m := c.take(reEveryWeekday); m != nil {
55 // "every Tuesday" is the weekly choice; which weekday it lands
56 // on comes from the event's own start date, so the day named
57 // here also has to set the date. takeDate sees it because the
58 // weekday word is left in place — only "every" is consumed.
59 reinstate(c, m[1])
60 return RepeatWeekly
61 }
62 if m := c.take(reEveryUnit); m != nil {
63 switch strings.ToLower(strings.Join(strings.Fields(m[1]), " ")) {
64 case "day":
65 return RepeatDaily
66 case "weekday":
67 return RepeatWeekdays
68 case "week":
69 return RepeatWeekly
70 case "fortnight", "two weeks":
71 return RepeatFortnight
72 case "month":
73 return RepeatMonthly
74 case "year":
75 return RepeatYearly
76 }
77 }
78 if m := c.take(reDailyWord); m != nil {
79 switch strings.ToLower(m[1]) {
80 case "daily":
81 return RepeatDaily
82 case "weekly":
83 return RepeatWeekly
84 case "fortnightly":
85 return RepeatFortnight
86 case "monthly":
87 return RepeatMonthly
88 case "yearly", "annually":
89 return RepeatYearly
90 }
91 }
92 return RepeatNone
93}
94
95// reinstate puts a word back into the sentence after its phrase was
96// consumed, so a later pass can still see it. Used for "every Tuesday",
97// where the repeat takes the phrase but the date still needs the day.
98func reinstate(c *consumed, word string) {
99 idx := strings.Index(strings.ToLower(string(c.src)), strings.ToLower(word))
100 if idx < 0 {
101 return
102 }
103 runes := []rune(string(c.src)[:idx])
104 from := len(runes)
105 for i, r := range []rune(word) {
106 if from+i < len(c.work) {
107 c.work[from+i] = r
108 }
109 }
110}
111
112// reLocation takes "in X" or "at X" and runs to the end of the clause.
113//
114// It runs LAST of the extractors, and that ordering is the whole reason
115// it can be this simple: by the time it sees the sentence every time,
116// date and duration has already been blanked, so an "at" still standing
117// is an "at" that no time claimed. Run this before takeTimes and every
118// event anybody types is in a place called "3pm".
119//
120// The clause ends at "with", because "lunch in the canteen with Bob"
121// has a place and a person and running to the end would swallow both.
122//
123// It also ends at a NUL, and that is not a detail. "Coffee in the
124// Fumbally at 11" has its time blanked from the MIDDLE of the string,
125// so the place is no longer at the end of it — anchoring on $ alone
126// found nothing at all and dropped the location silently.
127var reLocation = regexp.MustCompile(`(?i)\b(?:in|at)\s+([^\x00]+?)(?:\s+with\b[^\x00]*)?\s*(?:\x00|$)`)
128
129func takeLocation(c *consumed) string {
130 m := c.take(reLocation)
131 if m == nil {
132 return ""
133 }
134 loc := tidy(m[1])
135 // A bare connective is not a place: "meeting at" typed mid-sentence
136 // should leave the field empty rather than invent one.
137 if len(loc) < 2 {
138 return ""
139 }
140 return loc
141}
142