- List of the user's own calendars (web/calendar) - Month view per calendar with a Monday-first 6-week grid, showing all-day and timed events, colored dot matching the calendar's color - Navigation between months (prev/next/today) via query params - Create/edit/delete events: title, description, location, all-day toggle, start/end date+time - Clicking a day cell prefills a new all-day event on that date; the "New event" button defaults to a one-hour slot starting next full hour - Deleting an event or invalid time ranges (end before/equal start) are validated with inline error messages - Verified round-trip with the real CalDAV protocol handler (events created via the web UI are correctly visible to a REPORT query) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
629 lines
19 KiB
Go
629 lines
19 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net/http"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
ical "github.com/emersion/go-ical"
|
|
"github.com/yourusername/caldav-server/internal/web/templates"
|
|
)
|
|
|
|
// eventIDRe validates an event's object ID as it appears in a URL path
|
|
// segment: a filename like "3f9a2b8c1d4e5f60ab12cd34ef56ab78.ics".
|
|
var eventIDRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,128}\.ics$`)
|
|
|
|
// dateLayout/timeLayout are the HTML date/time input formats used
|
|
// throughout the event form.
|
|
const dateLayout = "2006-01-02"
|
|
const timeLayout = "15:04"
|
|
|
|
// newEventID generates a random filename for a new event object,
|
|
// mirroring the "<id>.ics" convention used by the CalDAV backend
|
|
// (internal/caldav).
|
|
func newEventID() (string, error) {
|
|
buf := make([]byte, 16)
|
|
if _, err := rand.Read(buf); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(buf) + ".ics", nil
|
|
}
|
|
|
|
// ownsCalendar reports whether cal is one of username's own calendars
|
|
// (never one shared with them by another user — the web calendar UI only
|
|
// manages a user's own calendars, consistent with contacts/files).
|
|
func (s *Server) ownsCalendar(username, cal string) (bool, error) {
|
|
if !resourceNameRe.MatchString(cal) {
|
|
return false, nil
|
|
}
|
|
cals, err := s.dbase.ListCalendars(username)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for _, c := range cals {
|
|
if c.Name == cal {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func (s *Server) handleCalendarsHome(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
w.Header().Set("Allow", "GET")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
username := userFromContext(r.Context())
|
|
cals, err := s.dbase.ListCalendars(username)
|
|
if err != nil {
|
|
s.logger.Error("listing calendars", "error", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
sort.Slice(cals, func(i, j int) bool { return cals[i].Name < cals[j].Name })
|
|
|
|
var summaries []templates.CalendarSummary
|
|
for _, c := range cals {
|
|
summaries = append(summaries, templates.CalendarSummary{Name: c.Name, Color: c.Color})
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_ = templates.CalendarsHome(username, summaries).Render(context.Background(), w)
|
|
}
|
|
|
|
func (s *Server) handleCalendarMonth(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
w.Header().Set("Allow", "GET")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
username := userFromContext(r.Context())
|
|
cal := r.PathValue("cal")
|
|
ok, err := s.ownsCalendar(username, cal)
|
|
if err != nil {
|
|
s.logger.Error("checking calendar ownership", "error", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
year, month := parseYearMonth(r)
|
|
color, _ := s.dbase.GetCalendarColor(username, cal)
|
|
|
|
data, err := s.buildMonthView(username, cal, color, year, month)
|
|
if err != nil {
|
|
s.logger.Error("building month view", "calendar", cal, "error", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_ = templates.MonthView(username, data).Render(context.Background(), w)
|
|
}
|
|
|
|
// parseYearMonth reads ?year=&month= query params, defaulting to the
|
|
// current month if absent or invalid.
|
|
func parseYearMonth(r *http.Request) (year int, month time.Month) {
|
|
now := time.Now()
|
|
year, month = now.Year(), now.Month()
|
|
if y, err := strconv.Atoi(r.URL.Query().Get("year")); err == nil && y >= 1 && y <= 9999 {
|
|
year = y
|
|
}
|
|
if m, err := strconv.Atoi(r.URL.Query().Get("month")); err == nil && m >= 1 && m <= 12 {
|
|
month = time.Month(m)
|
|
}
|
|
return year, month
|
|
}
|
|
|
|
// buildMonthView loads every event in cal, then places each occurrence's
|
|
// days onto a 6-week grid covering the requested month (plus enough
|
|
// leading/trailing days of neighboring months to fill full weeks).
|
|
// Recurring events (RRULE) are not expanded — only an event's own
|
|
// DTSTART/DTEND span is considered.
|
|
func (s *Server) buildMonthView(username, cal, color string, year int, month time.Month) (templates.MonthViewData, error) {
|
|
loc := time.Local
|
|
first := time.Date(year, month, 1, 0, 0, 0, 0, loc)
|
|
// Monday-first offset: time.Weekday has Sunday=0..Saturday=6.
|
|
offset := (int(first.Weekday()) + 6) % 7
|
|
gridStart := first.AddDate(0, 0, -offset)
|
|
const totalDays = 42 // 6 weeks
|
|
|
|
today := time.Now().In(loc).Format(dateLayout)
|
|
|
|
days := make([]templates.MonthDay, totalDays)
|
|
dayIndex := make(map[string]int, totalDays)
|
|
for i := 0; i < totalDays; i++ {
|
|
d := gridStart.AddDate(0, 0, i)
|
|
dateStr := d.Format(dateLayout)
|
|
days[i] = templates.MonthDay{
|
|
Date: dateStr,
|
|
Day: d.Day(),
|
|
InMonth: d.Month() == month,
|
|
IsToday: dateStr == today,
|
|
}
|
|
dayIndex[dateStr] = i
|
|
}
|
|
gridEnd := gridStart.AddDate(0, 0, totalDays-1)
|
|
|
|
ids, err := s.store.ListObjects(username, "cal-"+cal)
|
|
if err != nil {
|
|
return templates.MonthViewData{}, err
|
|
}
|
|
for _, id := range ids {
|
|
data, err := s.store.GetObject(username, "cal-"+cal, id)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
form, err := eventFormFromICS(id, data)
|
|
if err != nil {
|
|
s.logger.Warn("decoding calendar object", "calendar", cal, "id", id, "error", err)
|
|
continue
|
|
}
|
|
startDay, endDay, err := eventDayRange(form, loc)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if endDay.Before(gridStart) || startDay.After(gridEnd) {
|
|
continue
|
|
}
|
|
if startDay.Before(gridStart) {
|
|
startDay = gridStart
|
|
}
|
|
if endDay.After(gridEnd) {
|
|
endDay = gridEnd
|
|
}
|
|
// Cap the number of days a single event can add to the grid, in
|
|
// case of malformed data with a wildly distant DTEND.
|
|
for d, n := startDay, 0; !d.After(endDay) && n < totalDays; d, n = d.AddDate(0, 0, 1), n+1 {
|
|
idx, ok := dayIndex[d.Format(dateLayout)]
|
|
if !ok {
|
|
continue
|
|
}
|
|
timeText := ""
|
|
if !form.AllDay {
|
|
timeText = form.StartTime
|
|
}
|
|
days[idx].Events = append(days[idx].Events, templates.EventSummary{
|
|
ID: id,
|
|
Summary: form.Summary,
|
|
TimeText: timeText,
|
|
AllDay: form.AllDay,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Keep each day's events in a stable, readable order: all-day events
|
|
// first, then timed events sorted by start time.
|
|
for i := range days {
|
|
evs := days[i].Events
|
|
sort.SliceStable(evs, func(a, b int) bool {
|
|
if evs[a].AllDay != evs[b].AllDay {
|
|
return evs[a].AllDay
|
|
}
|
|
return evs[a].TimeText < evs[b].TimeText
|
|
})
|
|
}
|
|
|
|
weeks := make([][]templates.MonthDay, 0, 6)
|
|
for i := 0; i < totalDays; i += 7 {
|
|
weeks = append(weeks, days[i:i+7])
|
|
}
|
|
|
|
prevMonth := first.AddDate(0, -1, 0)
|
|
nextMonth := first.AddDate(0, 1, 0)
|
|
monthURL := func(y int, m time.Month) string {
|
|
return fmt.Sprintf("/web/calendar/%s?year=%d&month=%d", cal, y, int(m))
|
|
}
|
|
|
|
return templates.MonthViewData{
|
|
Cal: cal,
|
|
Color: color,
|
|
MonthLabel: first.Format("January 2006"),
|
|
Weeks: weeks,
|
|
PrevMonthURL: monthURL(prevMonth.Year(), prevMonth.Month()),
|
|
NextMonthURL: monthURL(nextMonth.Year(), nextMonth.Month()),
|
|
TodayURL: "/web/calendar/" + cal,
|
|
}, nil
|
|
}
|
|
|
|
// eventDayRange returns the inclusive [start, end] calendar-day span an
|
|
// event occupies, in loc, for placing it on the month grid.
|
|
func eventDayRange(form templates.EventFormData, loc *time.Location) (start, end time.Time, err error) {
|
|
start, err = time.ParseInLocation(dateLayout, form.StartDate, loc)
|
|
if err != nil {
|
|
return time.Time{}, time.Time{}, err
|
|
}
|
|
endDate := form.EndDate
|
|
if endDate == "" {
|
|
endDate = form.StartDate
|
|
}
|
|
end, err = time.ParseInLocation(dateLayout, endDate, loc)
|
|
if err != nil {
|
|
return time.Time{}, time.Time{}, err
|
|
}
|
|
if end.Before(start) {
|
|
end = start
|
|
}
|
|
return start, end, nil
|
|
}
|
|
|
|
func (s *Server) handleEventNew(w http.ResponseWriter, r *http.Request) {
|
|
username := userFromContext(r.Context())
|
|
cal := r.PathValue("cal")
|
|
ok, err := s.ownsCalendar(username, cal)
|
|
if err != nil {
|
|
s.logger.Error("checking calendar ownership", "error", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
form := newEventFormDefaults(cal, r.URL.Query().Get("date"))
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_ = templates.EventForm(username, form, "").Render(context.Background(), w)
|
|
case http.MethodPost:
|
|
s.saveEventFromForm(w, r, username, cal, "")
|
|
default:
|
|
w.Header().Set("Allow", "GET, POST")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
// newEventFormDefaults builds the initial form values for a brand new
|
|
// event. If dateParam is a valid "YYYY-MM-DD" (e.g. from clicking a day
|
|
// cell in the month grid), the event defaults to an all-day event on that
|
|
// date; otherwise it defaults to a one-hour timed event starting at the
|
|
// next full hour today.
|
|
func newEventFormDefaults(cal, dateParam string) templates.EventFormData {
|
|
if _, err := time.Parse(dateLayout, dateParam); err == nil {
|
|
return templates.EventFormData{Cal: cal, AllDay: true, StartDate: dateParam, EndDate: dateParam}
|
|
}
|
|
now := time.Now()
|
|
start := now.Truncate(time.Hour).Add(time.Hour)
|
|
end := start.Add(time.Hour)
|
|
return templates.EventFormData{
|
|
Cal: cal,
|
|
StartDate: start.Format(dateLayout),
|
|
StartTime: start.Format(timeLayout),
|
|
EndDate: end.Format(dateLayout),
|
|
EndTime: end.Format(timeLayout),
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleEventEdit(w http.ResponseWriter, r *http.Request) {
|
|
username := userFromContext(r.Context())
|
|
cal := r.PathValue("cal")
|
|
id := r.PathValue("id")
|
|
ok, err := s.ownsCalendar(username, cal)
|
|
if err != nil {
|
|
s.logger.Error("checking calendar ownership", "error", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !ok || !eventIDRe.MatchString(id) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
data, err := s.store.GetObject(username, "cal-"+cal, id)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
form, err := eventFormFromICS(id, data)
|
|
if err != nil {
|
|
s.logger.Error("decoding event", "error", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
form.Cal = cal
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_ = templates.EventForm(username, form, "").Render(context.Background(), w)
|
|
case http.MethodPost:
|
|
s.saveEventFromForm(w, r, username, cal, id)
|
|
default:
|
|
w.Header().Set("Allow", "GET, POST")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleEventDelete(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.Header().Set("Allow", "POST")
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
username := userFromContext(r.Context())
|
|
cal := r.PathValue("cal")
|
|
id := r.PathValue("id")
|
|
ok, err := s.ownsCalendar(username, cal)
|
|
if err != nil {
|
|
s.logger.Error("checking calendar ownership", "error", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if !ok || !eventIDRe.MatchString(id) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if err := s.store.DeleteObject(username, "cal-"+cal, id); err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/web/calendar/"+cal, http.StatusSeeOther)
|
|
}
|
|
|
|
// eventFormInput holds the parsed, not-yet-validated values submitted by
|
|
// the event form.
|
|
type eventFormInput struct {
|
|
Summary string
|
|
Description string
|
|
Location string
|
|
AllDay bool
|
|
StartDate string
|
|
StartTime string
|
|
EndDate string
|
|
EndTime string
|
|
}
|
|
|
|
func parseEventForm(r *http.Request) eventFormInput {
|
|
return eventFormInput{
|
|
Summary: strings.TrimSpace(r.PostForm.Get("summary")),
|
|
Description: strings.TrimSpace(r.PostForm.Get("description")),
|
|
Location: strings.TrimSpace(r.PostForm.Get("location")),
|
|
AllDay: r.PostForm.Get("all_day") != "",
|
|
StartDate: strings.TrimSpace(r.PostForm.Get("start_date")),
|
|
StartTime: strings.TrimSpace(r.PostForm.Get("start_time")),
|
|
EndDate: strings.TrimSpace(r.PostForm.Get("end_date")),
|
|
EndTime: strings.TrimSpace(r.PostForm.Get("end_time")),
|
|
}
|
|
}
|
|
|
|
// saveEventFromForm handles both event creation (id == "") and editing
|
|
// (id preserves the existing event's UID).
|
|
func (s *Server) saveEventFromForm(w http.ResponseWriter, r *http.Request, username, cal, id string) {
|
|
if err := r.ParseForm(); err != nil {
|
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
|
return
|
|
}
|
|
in := parseEventForm(r)
|
|
|
|
reRender := func(errMsg string) {
|
|
form := templates.EventFormData{
|
|
Cal: cal, ID: id, Summary: in.Summary, Description: in.Description, Location: in.Location,
|
|
AllDay: in.AllDay, StartDate: in.StartDate, StartTime: in.StartTime, EndDate: in.EndDate, EndTime: in.EndTime,
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
_ = templates.EventForm(username, form, errMsg).Render(context.Background(), w)
|
|
}
|
|
|
|
if in.Summary == "" {
|
|
reRender("Title is required")
|
|
return
|
|
}
|
|
|
|
start, end, err := computeEventTimes(in)
|
|
if err != nil {
|
|
reRender(err.Error())
|
|
return
|
|
}
|
|
|
|
var uid string
|
|
if id == "" {
|
|
newID, err := newEventID()
|
|
if err != nil {
|
|
s.logger.Error("generating event id", "error", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
id = newID
|
|
uid = strings.TrimSuffix(id, ".ics")
|
|
} else {
|
|
data, err := s.store.GetObject(username, "cal-"+cal, id)
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
existing, err := ical.NewDecoder(strings.NewReader(string(data))).Decode()
|
|
if err != nil {
|
|
s.logger.Error("decoding existing event", "error", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if events := existing.Events(); len(events) > 0 {
|
|
if uidProp := events[0].Props.Get(ical.PropUID); uidProp != nil {
|
|
uid = uidProp.Value
|
|
}
|
|
}
|
|
if uid == "" {
|
|
uid = strings.TrimSuffix(id, ".ics")
|
|
}
|
|
}
|
|
|
|
newCal := buildEventCalendar(uid, in, start, end)
|
|
if err := s.saveEvent(username, cal, id, newCal); err != nil {
|
|
s.logger.Error("saving event", "error", err)
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
http.Redirect(w, r, "/web/calendar/"+cal, http.StatusSeeOther)
|
|
}
|
|
|
|
// computeEventTimes validates and converts the submitted form fields into
|
|
// concrete start/end instants (for timed events) or dates (for all-day
|
|
// events, where end is returned as the exclusive DTEND day).
|
|
func computeEventTimes(in eventFormInput) (start, end time.Time, err error) {
|
|
if in.StartDate == "" {
|
|
return time.Time{}, time.Time{}, fmt.Errorf("start date is required")
|
|
}
|
|
endDate := in.EndDate
|
|
if endDate == "" {
|
|
endDate = in.StartDate
|
|
}
|
|
|
|
if in.AllDay {
|
|
start, err = time.ParseInLocation(dateLayout, in.StartDate, time.Local)
|
|
if err != nil {
|
|
return time.Time{}, time.Time{}, fmt.Errorf("invalid start date")
|
|
}
|
|
end, err = time.ParseInLocation(dateLayout, endDate, time.Local)
|
|
if err != nil {
|
|
return time.Time{}, time.Time{}, fmt.Errorf("invalid end date")
|
|
}
|
|
if end.Before(start) {
|
|
return time.Time{}, time.Time{}, fmt.Errorf("end date must not be before start date")
|
|
}
|
|
// DTEND for all-day events is exclusive: the end date the user
|
|
// picks is the last day the event covers, so add one day.
|
|
end = end.AddDate(0, 0, 1)
|
|
return start, end, nil
|
|
}
|
|
|
|
if in.StartTime == "" || in.EndTime == "" {
|
|
return time.Time{}, time.Time{}, fmt.Errorf("start and end time are required for timed events")
|
|
}
|
|
start, err = time.ParseInLocation(dateLayout+"T"+timeLayout, in.StartDate+"T"+in.StartTime, time.Local)
|
|
if err != nil {
|
|
return time.Time{}, time.Time{}, fmt.Errorf("invalid start date/time")
|
|
}
|
|
end, err = time.ParseInLocation(dateLayout+"T"+timeLayout, endDate+"T"+in.EndTime, time.Local)
|
|
if err != nil {
|
|
return time.Time{}, time.Time{}, fmt.Errorf("invalid end date/time")
|
|
}
|
|
if !end.After(start) {
|
|
return time.Time{}, time.Time{}, fmt.Errorf("end must be after start")
|
|
}
|
|
return start, end, nil
|
|
}
|
|
|
|
// buildEventCalendar builds a fresh VCALENDAR/VEVENT from the submitted
|
|
// form input and resolved start/end instants.
|
|
func buildEventCalendar(uid string, in eventFormInput, start, end time.Time) *ical.Calendar {
|
|
cal := ical.NewCalendar()
|
|
cal.Props.SetText(ical.PropVersion, "2.0")
|
|
cal.Props.SetText(ical.PropProductID, "-//nidus//web calendar//EN")
|
|
|
|
ev := ical.NewEvent()
|
|
ev.Props.SetText(ical.PropUID, uid)
|
|
ev.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC())
|
|
ev.Props.SetText(ical.PropSummary, in.Summary)
|
|
if in.Description != "" {
|
|
ev.Props.SetText(ical.PropDescription, in.Description)
|
|
}
|
|
if in.Location != "" {
|
|
ev.Props.SetText(ical.PropLocation, in.Location)
|
|
}
|
|
|
|
if in.AllDay {
|
|
ev.Props.SetDate(ical.PropDateTimeStart, start)
|
|
ev.Props.SetDate(ical.PropDateTimeEnd, end)
|
|
} else {
|
|
ev.Props.SetDateTime(ical.PropDateTimeStart, start.UTC())
|
|
ev.Props.SetDateTime(ical.PropDateTimeEnd, end.UTC())
|
|
}
|
|
|
|
cal.Children = append(cal.Children, ev.Component)
|
|
return cal
|
|
}
|
|
|
|
func (s *Server) saveEvent(username, cal, id string, calendar *ical.Calendar) error {
|
|
var buf strings.Builder
|
|
enc := ical.NewEncoder(&buf)
|
|
if err := enc.Encode(calendar); err != nil {
|
|
return fmt.Errorf("encoding ical: %w", err)
|
|
}
|
|
return s.store.PutObject(username, "cal-"+cal, id, []byte(buf.String()))
|
|
}
|
|
|
|
// eventFormFromICS decodes a single-VEVENT .ics object into the shared
|
|
// EventFormData shape used both for pre-filling the edit form and for
|
|
// placing the event on the month grid.
|
|
func eventFormFromICS(id string, data []byte) (templates.EventFormData, error) {
|
|
calendar, err := ical.NewDecoder(strings.NewReader(string(data))).Decode()
|
|
if err != nil {
|
|
return templates.EventFormData{}, err
|
|
}
|
|
events := calendar.Events()
|
|
if len(events) == 0 {
|
|
return templates.EventFormData{}, fmt.Errorf("no VEVENT in %s", id)
|
|
}
|
|
ev := events[0]
|
|
|
|
form := templates.EventFormData{ID: id}
|
|
if p := ev.Props.Get(ical.PropSummary); p != nil {
|
|
form.Summary = p.Value
|
|
}
|
|
if p := ev.Props.Get(ical.PropDescription); p != nil {
|
|
form.Description = p.Value
|
|
}
|
|
if p := ev.Props.Get(ical.PropLocation); p != nil {
|
|
form.Location = p.Value
|
|
}
|
|
|
|
startProp := ev.Props.Get(ical.PropDateTimeStart)
|
|
endProp := ev.Props.Get(ical.PropDateTimeEnd)
|
|
if startProp == nil {
|
|
return templates.EventFormData{}, fmt.Errorf("missing DTSTART in %s", id)
|
|
}
|
|
|
|
allDay := startProp.ValueType() == ical.ValueDate || len(startProp.Value) == 8 // RFC 5545 DATE value, e.g. "20060102"
|
|
form.AllDay = allDay
|
|
|
|
if allDay {
|
|
start, err := startProp.DateTime(time.Local)
|
|
if err != nil {
|
|
return templates.EventFormData{}, err
|
|
}
|
|
form.StartDate = start.Format(dateLayout)
|
|
end := start.AddDate(0, 0, 1)
|
|
if endProp != nil {
|
|
if e, err := endProp.DateTime(time.Local); err == nil {
|
|
end = e
|
|
}
|
|
}
|
|
// Displayed end date is the last inclusive day (DTEND is exclusive).
|
|
form.EndDate = end.AddDate(0, 0, -1).Format(dateLayout)
|
|
} else {
|
|
// go-ical's Prop.DateTime always parses a "Z"-suffixed UTC value
|
|
// as UTC regardless of the loc argument passed in, so the
|
|
// returned time must be explicitly converted to the local zone
|
|
// before formatting for display.
|
|
start, err := startProp.DateTime(time.Local)
|
|
if err != nil {
|
|
return templates.EventFormData{}, err
|
|
}
|
|
start = start.In(time.Local)
|
|
form.StartDate = start.Format(dateLayout)
|
|
form.StartTime = start.Format(timeLayout)
|
|
end := start.Add(time.Hour)
|
|
if endProp != nil {
|
|
if e, err := endProp.DateTime(time.Local); err == nil {
|
|
end = e.In(time.Local)
|
|
}
|
|
}
|
|
form.EndDate = end.Format(dateLayout)
|
|
form.EndTime = end.Format(timeLayout)
|
|
}
|
|
|
|
return form, nil
|
|
}
|