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 "fmt"
5 "strings"
6)
7
8// MaxTitleLen and friends bound what a form may store. Not security —
9// the columns are sealed and SQLite does not care — but a title nobody
10// can read in a chip is a title nobody wanted, and an unbounded notes
11// field is an unbounded row in a file that gets replicated.
12const (
13 MaxTitleLen = 200
14 MaxLocationLen = 200
15 MaxNotesLen = 4000
16)
17
18// ValidationError is a message meant for the person who submitted the
19// form, beside the field it is about. It carries no stored content.
20type ValidationError struct {
21 Field string
22 Message string
23}
24
25func (e ValidationError) Error() string { return e.Field + ": " + e.Message }
26
27// Invalid reports whether err is a ValidationError, so a handler can
28// re-render the form with the message rather than answering 500.
29func Invalid(err error) (ValidationError, bool) {
30 var v ValidationError
31 if err == nil {
32 return v, false
33 }
34 if ve, ok := err.(ValidationError); ok {
35 return ve, true
36 }
37 return v, false
38}
39
40// ValidateEventInput normalises in place and reports the first problem.
41//
42// Normalising here rather than in the handler means the store cannot be
43// handed an event with an unparseable time or a kind nothing renders,
44// whatever calls it — including the ICS importer, which is the caller
45// most likely to supply something strange.
46func ValidateEventInput(in *EventInput) error {
47 in.Title = strings.TrimSpace(in.Title)
48 in.Location = strings.TrimSpace(in.Location)
49 in.Notes = strings.TrimSpace(in.Notes)
50 in.TZ = strings.TrimSpace(in.TZ)
51 in.RRule = strings.TrimSpace(in.RRule)
52
53 if in.Title == "" {
54 return ValidationError{"title", "Give it a name."}
55 }
56 if len(in.Title) > MaxTitleLen {
57 return ValidationError{"title", fmt.Sprintf("Keep it under %d characters.", MaxTitleLen)}
58 }
59 if len(in.Location) > MaxLocationLen {
60 return ValidationError{"location", fmt.Sprintf("Keep it under %d characters.", MaxLocationLen)}
61 }
62 if len(in.Notes) > MaxNotesLen {
63 return ValidationError{"notes", fmt.Sprintf("Keep it under %d characters.", MaxNotesLen)}
64 }
65
66 switch in.Kind {
67 case KindMeeting, KindCommitment, KindNote:
68 case "":
69 in.Kind = KindMeeting
70 default:
71 return ValidationError{"kind", "Pick a meeting, a commitment or a note."}
72 }
73 switch in.Status {
74 case StatusConfirmed, StatusTentative, StatusCancelled:
75 case "":
76 in.Status = StatusConfirmed
77 default:
78 return ValidationError{"status", "Pick a status."}
79 }
80
81 // A timed event needs a zone; an all-day one must not have its zone
82 // consulted at all, and carrying one would invite a later reader to
83 // use it.
84 if in.AllDay {
85 in.TZ = ""
86 } else if in.TZ == "" {
87 return ValidationError{"tz", "Say which time zone this is in."}
88 }
89 if !in.AllDay {
90 if _, err := Zone(in.TZ); err != nil {
91 return ValidationError{"tz", "That is not a time zone we know."}
92 }
93 }
94
95 start, err := ToUTC(in.StartLocal, in.TZ, in.AllDay)
96 if err != nil {
97 return ValidationError{"start", "That is not a time we can read."}
98 }
99 if strings.TrimSpace(in.EndLocal) == "" {
100 return ValidationError{"end", "Say when it ends."}
101 }
102 end, err := ToUTC(in.EndLocal, in.TZ, in.AllDay)
103 if err != nil {
104 return ValidationError{"end", "That is not a time we can read."}
105 }
106
107 // End is exclusive, so equal is empty and earlier is backwards.
108 // Both are refused rather than silently corrected: an event that
109 // quietly moved its own end is a worse surprise than a rejected
110 // form.
111 if end < start {
112 return ValidationError{"end", "It cannot end before it starts."}
113 }
114 if end == start && in.AllDay {
115 return ValidationError{"end", "An all-day event ends on the following day."}
116 }
117 return nil
118}
119