| 1 | package calendar |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strings" |
| 6 | "time" |
| 7 | ) |
| 8 | |
| 9 | // The repeat vocabulary the form offers. |
| 10 | // |
| 11 | // Six choices, not an RRULE builder. A repeat picker that exposes |
| 12 | // FREQ, INTERVAL, BYDAY, BYSETPOS and COUNT as separate controls is a |
| 13 | // picker that makes somebody learn a specification to book a standup. |
| 14 | // These six cover what people actually schedule, and each maps to one |
| 15 | // unambiguous rule anchored on the event's own start date. |
| 16 | // |
| 17 | // Anything more expressive arrives by import, is stored verbatim, and |
| 18 | // is shown on the event's page as the rule it is. |
| 19 | const ( |
| 20 | RepeatNone = "none" |
| 21 | RepeatDaily = "daily" |
| 22 | RepeatWeekdays = "weekdays" |
| 23 | RepeatWeekly = "weekly" |
| 24 | RepeatFortnight = "fortnightly" |
| 25 | RepeatMonthly = "monthly" |
| 26 | RepeatYearly = "yearly" |
| 27 | ) |
| 28 | |
| 29 | // RepeatChoice is one option in the picker. |
| 30 | type RepeatChoice struct { |
| 31 | Value string |
| 32 | Label string |
| 33 | } |
| 34 | |
| 35 | // RepeatChoicesFor labels the options against the event's own start |
| 36 | // date, so the picker says "Every Wednesday" rather than "Weekly" and |
| 37 | // nobody has to work out what the rule will mean. |
| 38 | func RepeatChoicesFor(startLocal, zone string, allDay bool) []RepeatChoice { |
| 39 | out := []RepeatChoice{ |
| 40 | {RepeatNone, "Does not repeat"}, |
| 41 | {RepeatDaily, "Every day"}, |
| 42 | {RepeatWeekdays, "Every weekday (Monday to Friday)"}, |
| 43 | } |
| 44 | loc, err := Zone(zone) |
| 45 | if err != nil { |
| 46 | loc = time.UTC |
| 47 | } |
| 48 | t, _, err := ParseLocal(startLocal, loc) |
| 49 | if err != nil { |
| 50 | return append(out, |
| 51 | RepeatChoice{RepeatWeekly, "Every week"}, |
| 52 | RepeatChoice{RepeatFortnight, "Every two weeks"}, |
| 53 | RepeatChoice{RepeatMonthly, "Every month"}, |
| 54 | RepeatChoice{RepeatYearly, "Every year"}, |
| 55 | ) |
| 56 | } |
| 57 | return append(out, |
| 58 | RepeatChoice{RepeatWeekly, "Every " + t.Format("Monday")}, |
| 59 | RepeatChoice{RepeatFortnight, "Every second " + t.Format("Monday")}, |
| 60 | RepeatChoice{RepeatMonthly, "Every month on the " + ordinal(t.Day())}, |
| 61 | RepeatChoice{RepeatYearly, "Every year on " + t.Format("2 January")}, |
| 62 | ) |
| 63 | } |
| 64 | |
| 65 | // RuleFromRepeat turns a picker choice into an RRULE anchored on the |
| 66 | // event's start. |
| 67 | // |
| 68 | // repeatEnd is an optional "until" date from the form. It is emitted as |
| 69 | // a bare local date rather than a UTC instant, because that is what it |
| 70 | // is — somebody said "until the 31st", not "until 23:59:59Z on the |
| 71 | // 31st", and converting it would move the last day for anyone whose |
| 72 | // zone is far enough from UTC. |
| 73 | func RuleFromRepeat(repeat, repeatEnd, startLocal, zone string, allDay bool) string { |
| 74 | var rule string |
| 75 | switch repeat { |
| 76 | case "", RepeatNone: |
| 77 | return "" |
| 78 | case RepeatDaily: |
| 79 | rule = "FREQ=DAILY" |
| 80 | case RepeatWeekdays: |
| 81 | rule = "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR" |
| 82 | case RepeatWeekly: |
| 83 | rule = "FREQ=WEEKLY" |
| 84 | case RepeatFortnight: |
| 85 | rule = "FREQ=WEEKLY;INTERVAL=2" |
| 86 | case RepeatMonthly: |
| 87 | rule = "FREQ=MONTHLY" |
| 88 | case RepeatYearly: |
| 89 | rule = "FREQ=YEARLY" |
| 90 | default: |
| 91 | return "" |
| 92 | } |
| 93 | if end := strings.TrimSpace(repeatEnd); end != "" { |
| 94 | if t, err := time.Parse(LayoutDate, end); err == nil { |
| 95 | rule += ";UNTIL=" + t.Format("20060102") |
| 96 | } |
| 97 | } |
| 98 | return rule |
| 99 | } |
| 100 | |
| 101 | // RepeatFromRule is the inverse, so the edit form can reopen showing |
| 102 | // what was chosen. A rule outside the six reads back as "none" and the |
| 103 | // form says so rather than silently offering to replace it. |
| 104 | func RepeatFromRule(rrule string) string { |
| 105 | if strings.TrimSpace(rrule) == "" { |
| 106 | return RepeatNone |
| 107 | } |
| 108 | r, err := ParseRule(rrule) |
| 109 | if err != nil { |
| 110 | return RepeatNone |
| 111 | } |
| 112 | switch r.Freq { |
| 113 | case FreqDaily: |
| 114 | if r.Interval == 1 { |
| 115 | return RepeatDaily |
| 116 | } |
| 117 | case FreqWeekly: |
| 118 | weekdaysOnly := len(r.ByDay) == 5 && |
| 119 | containsWeekday(r.ByDay, time.Monday) && containsWeekday(r.ByDay, time.Friday) && |
| 120 | !containsWeekday(r.ByDay, time.Saturday) && !containsWeekday(r.ByDay, time.Sunday) |
| 121 | switch { |
| 122 | case weekdaysOnly && r.Interval == 1: |
| 123 | return RepeatWeekdays |
| 124 | case len(r.ByDay) == 0 && r.Interval == 1: |
| 125 | return RepeatWeekly |
| 126 | case len(r.ByDay) == 0 && r.Interval == 2: |
| 127 | return RepeatFortnight |
| 128 | } |
| 129 | case FreqMonthly: |
| 130 | if r.Interval == 1 && r.MonthDay == 0 && r.Ordinal == 0 { |
| 131 | return RepeatMonthly |
| 132 | } |
| 133 | case FreqYearly: |
| 134 | if r.Interval == 1 { |
| 135 | return RepeatYearly |
| 136 | } |
| 137 | } |
| 138 | return RepeatNone |
| 139 | } |
| 140 | |
| 141 | // DescribeRule writes a rule as a sentence for the event's page. |
| 142 | // |
| 143 | // A rule this app cannot express is described as itself — the raw |
| 144 | // RRULE, labelled — rather than approximated. An approximation of a |
| 145 | // recurrence is a lie about when something happens. |
| 146 | func DescribeRule(rrule string) string { |
| 147 | if strings.TrimSpace(rrule) == "" { |
| 148 | return "" |
| 149 | } |
| 150 | r, err := ParseRule(rrule) |
| 151 | if err != nil { |
| 152 | return "Repeats on an imported rule this app does not edit: " + rrule |
| 153 | } |
| 154 | |
| 155 | var base string |
| 156 | switch r.Freq { |
| 157 | case FreqDaily: |
| 158 | base = everyN(r.Interval, "day") |
| 159 | case FreqWeekly: |
| 160 | if len(r.ByDay) > 0 { |
| 161 | var names []string |
| 162 | for _, d := range r.ByDay { |
| 163 | names = append(names, d.String()) |
| 164 | } |
| 165 | base = everyN(r.Interval, "week") + " on " + joinWords(names) |
| 166 | } else { |
| 167 | base = everyN(r.Interval, "week") |
| 168 | } |
| 169 | case FreqMonthly: |
| 170 | switch { |
| 171 | case r.Ordinal != 0: |
| 172 | base = fmt.Sprintf("%s on the %s %s", everyN(r.Interval, "month"), |
| 173 | ordinalWord(r.Ordinal), r.OrdinalDay.String()) |
| 174 | case r.MonthDay > 0: |
| 175 | base = fmt.Sprintf("%s on the %s", everyN(r.Interval, "month"), ordinal(r.MonthDay)) |
| 176 | default: |
| 177 | base = everyN(r.Interval, "month") |
| 178 | } |
| 179 | case FreqYearly: |
| 180 | base = everyN(r.Interval, "year") |
| 181 | } |
| 182 | |
| 183 | switch { |
| 184 | case r.Count > 0: |
| 185 | return fmt.Sprintf("%s, %d times.", base, r.Count) |
| 186 | case r.HasUntil: |
| 187 | return fmt.Sprintf("%s, until %s.", base, r.Until.Format("2 January 2006")) |
| 188 | default: |
| 189 | return base + "." |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | func everyN(n int, unit string) string { |
| 194 | switch n { |
| 195 | case 1: |
| 196 | return "Every " + unit |
| 197 | case 2: |
| 198 | return "Every second " + unit |
| 199 | default: |
| 200 | return fmt.Sprintf("Every %d %ss", n, unit) |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | func ordinal(n int) string { |
| 205 | suffix := "th" |
| 206 | // 11th, 12th and 13th are the exceptions the naive rule gets wrong. |
| 207 | if n%100 < 11 || n%100 > 13 { |
| 208 | switch n % 10 { |
| 209 | case 1: |
| 210 | suffix = "st" |
| 211 | case 2: |
| 212 | suffix = "nd" |
| 213 | case 3: |
| 214 | suffix = "rd" |
| 215 | } |
| 216 | } |
| 217 | return fmt.Sprintf("%d%s", n, suffix) |
| 218 | } |
| 219 | |
| 220 | func ordinalWord(n int) string { |
| 221 | switch n { |
| 222 | case 1: |
| 223 | return "first" |
| 224 | case 2: |
| 225 | return "second" |
| 226 | case 3: |
| 227 | return "third" |
| 228 | case 4: |
| 229 | return "fourth" |
| 230 | case -1: |
| 231 | return "last" |
| 232 | default: |
| 233 | return ordinal(n) |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | func joinWords(w []string) string { |
| 238 | switch len(w) { |
| 239 | case 0: |
| 240 | return "" |
| 241 | case 1: |
| 242 | return w[0] |
| 243 | case 2: |
| 244 | return w[0] + " and " + w[1] |
| 245 | default: |
| 246 | return strings.Join(w[:len(w)-1], ", ") + " and " + w[len(w)-1] |
| 247 | } |
| 248 | } |
| 249 | |