| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 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. |
| 10 | var 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. |
| 18 | func 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 | |
| 35 | var reAllDay = regexp.MustCompile(`(?i)\b(all[\s-]?day)\b`) |
| 36 | |
| 37 | func takeAllDay(c *consumed) bool { return c.take(reAllDay) != nil } |
| 38 | |
| 39 | var ( |
| 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. |
| 53 | func 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. |
| 98 | func 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. |
| 127 | var reLocation = regexp.MustCompile(`(?i)\b(?:in|at)\s+([^\x00]+?)(?:\s+with\b[^\x00]*)?\s*(?:\x00|$)`) |
| 128 | |
| 129 | func 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 | |