1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
package main

import (
	_ "embed"
	htmltemplate "html/template"
	"net/http"
	"strings"
	"text/template"
	"time"

	"codeberg.org/Profpatsch/Profpatsch/users/Profpatsch/mailtext"
)

// appName appears in every delimiter marker.
const appName = "calweb"

//go:embed templates/list.llm
var listSrc string

//go:embed templates/event.llm
var eventSrc string

//go:embed templates/proposed.llm
var proposedSrc string

//go:embed templates/proposals.llm
var proposalsSrc string

//go:embed templates/proposal.llm
var proposalSrc string

//go:embed templates/proposal.html
var proposalHTMLSrc string

//go:embed templates/error.llm
var errorSrc string

func mustParse(name, src string) *template.Template {
	return template.Must(template.New(name).Funcs(mailtext.ParseFuncs).Parse(src))
}

var (
	listTmpl      = mustParse("list.llm", listSrc)
	eventTmpl     = mustParse("event.llm", eventSrc)
	proposedTmpl  = mustParse("proposed.llm", proposedSrc)
	proposalsTmpl = mustParse("proposals.llm", proposalsSrc)
	proposalTmpl  = mustParse("proposal.llm", proposalSrc)
	errorTmpl     = mustParse("error.llm", errorSrc)

	proposalHTMLTmpl = htmltemplate.Must(htmltemplate.New("proposal.html").Parse(proposalHTMLSrc))
)

// writeLLM renders a text/llm response with a status. Every route except the
// confirm page answers this way whatever was asked for: there is no HTML to
// fall back to, and a browser shows text/plain fine.
func writeLLM(w http.ResponseWriter, r *http.Request, tmpl *template.Template, data any, status int) {
	if status != http.StatusOK {
		// mailtext.Write sets the content type and writes the body; the
		// status has to be committed by the caller first, but only after the
		// headers are set. Wrap the writer to defer it.
		w = &statusWriter{ResponseWriter: w, status: status}
	}
	if !mailtext.WantsLLM(r) {
		// Asked for nothing in particular (a browser, a bare curl): serve the
		// text as text/plain so a browser displays it rather than offering a
		// download of an unknown type.
		q := r.URL.Query()
		q.Set(mailtext.ViewParam, mailtext.ViewValue)
		r2 := r.Clone(r.Context())
		r2.URL.RawQuery = q.Encode()
		r = r2
	}
	mailtext.Write(w, r, appName, tmpl, data)
}

type statusWriter struct {
	http.ResponseWriter
	status  int
	written bool
}

func (sw *statusWriter) WriteHeader(code int) {
	sw.written = true
	sw.ResponseWriter.WriteHeader(code)
}

func (sw *statusWriter) Write(b []byte) (int, error) {
	if !sw.written {
		// An error page from inside mailtext.Write has already called
		// WriteHeader; only a successful render reaches here unwritten.
		sw.WriteHeader(sw.status)
	}
	return sw.ResponseWriter.Write(b)
}

// ============================================================================
// Events
// ============================================================================

// eventView is an event prepared for a template. Untrusted strings stay raw;
// the templates pass them through the escaping helpers.
type eventView struct {
	ID          string
	When        string
	Summary     string
	Location    string
	Description string
	Status      string
	Recurring   bool
	SeriesID    string
	Recurrence  []string
	Organizer   string
	OrganizerMe bool
	Attendees   []string
	Link        string
	Refusal     string
}

func (s *server) view(ev apiEvent) eventView {
	v := eventView{
		ID:          ev.ID,
		When:        when(ev, s.loc),
		Summary:     ev.Summary,
		Location:    ev.Location,
		Description: plainDescription(ev.Description),
		Status:      ev.Status,
		Recurring:   ev.RecurringEventID != "",
		SeriesID:    ev.RecurringEventID,
		Recurrence:  ev.Recurrence,
		Organizer:   personLabel(ev.Organizer),
		OrganizerMe: ev.Organizer.Self,
		Link:        ev.HTMLLink,
		Refusal:     editRefusal(ev),
	}
	for _, a := range ev.Attendees {
		l := personLabel(a)
		if a.ResponseStatus != "" {
			l += " (" + a.ResponseStatus + ")"
		}
		v.Attendees = append(v.Attendees, l)
	}
	return v
}

// plainDescription turns an event description into text. Google stores what
// its own editor produced, which is HTML, alongside plain text from other
// clients; telling them apart by content is what mailtext does for mail.
func plainDescription(s string) string {
	s = strings.TrimSpace(s)
	if s == "" {
		return ""
	}
	if strings.Contains(s, "<") && strings.Contains(s, ">") {
		return strings.TrimSpace(mailtext.HTMLToText(s))
	}
	return s
}

type dayGroup struct {
	Day    string
	Events []eventView
}

func (s *server) groupByDay(evs []apiEvent) []dayGroup {
	var out []dayGroup
	for _, ev := range evs {
		start, _, err := ev.Start.parsed(s.loc)
		label := "(unreadable date)"
		if err == nil {
			label = dayLabel(start)
		}
		if len(out) == 0 || out[len(out)-1].Day != label {
			out = append(out, dayGroup{Day: label})
		}
		out[len(out)-1].Events = append(out[len(out)-1].Events, s.view(ev))
	}
	return out
}

type listData struct {
	Title    string
	Query    string
	From, To time.Time
	Capped   bool
	Days     []dayGroup
	Count    int
}

// Range renders the window inclusively, as a person names it: an agenda to
// midnight on the 16th covers "until the 15th".
func (d listData) Range() string {
	return dayLabel(d.From) + " – " + dayLabel(d.To.Add(-time.Second))
}

func (d listData) Max() int { return maxEvents }

type eventData struct {
	Ev      eventView
	Fetched time.Time
}

// ============================================================================
// Proposals
// ============================================================================

type proposalView struct {
	Token     string
	Kind      string
	Verb      string
	EventID   string
	Rows      []fieldRow
	Created   string
	Open      bool
	Applied   string
	Discarded string
	Result    *eventView
	Title     string
}

func (s *server) proposalView(p proposal) proposalView {
	v := proposalView{
		Token:   p.Token,
		Kind:    string(p.Kind),
		EventID: p.EventID,
		Rows:    p.rows(s.loc),
		Created: p.CreatedAt.In(s.loc).Format("2006-01-02 15:04"),
		Open:    p.Open(),
	}
	switch p.Kind {
	case kindCreate:
		v.Verb = "Create this event"
	case kindEdit:
		v.Verb = "Change this event"
	case kindDelete:
		v.Verb = "Delete this event"
	}
	for _, r := range v.Rows {
		if r.Field == "title" {
			v.Title = r.After
			if v.Title == "" {
				v.Title = r.Before
			}
		}
	}
	if !p.AppliedAt.IsZero() {
		v.Applied = p.AppliedAt.In(s.loc).Format("2006-01-02 15:04")
	}
	if !p.DiscardedAt.IsZero() {
		v.Discarded = p.DiscardedAt.In(s.loc).Format("2006-01-02 15:04")
	}
	if p.Result != nil {
		rv := s.view(*p.Result)
		v.Result = &rv
	}
	return v
}

func (s *server) proposalViews(ps []proposal) []proposalView {
	out := make([]proposalView, 0, len(ps))
	for _, p := range ps {
		out = append(out, s.proposalView(p))
	}
	return out
}

type proposedData struct {
	Token  string
	Kind   string
	Listen string
}

type proposalsData struct {
	Open, Closed []proposalView
}

type errorData struct {
	Status  int
	Message string
}