package alpscaldav
import (
"fmt"
"net/http"
"net/url"
"path"
"strings"
"time"
"git.sr.ht/~emersion/alps"
"github.com/emersion/go-ical"
"github.com/emersion/go-webdav/caldav"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
)
type CalendarRenderData struct {
alps.BaseRenderData
Time time.Time
Now time.Time
Dates [7 * 6]time.Time
Calendar *caldav.Calendar
Events []CalendarObject
PrevPage, NextPage string
PrevTime, NextTime time.Time
EventsForDate func(time.Time) []CalendarObject
DaySuffix func(n int) string
Sub func(a, b int) int
}
type CalendarDateRenderData struct {
alps.BaseRenderData
Time time.Time
Calendar *caldav.Calendar
Events []CalendarObject
PrevPage, NextPage string
}
type EventRenderData struct {
alps.BaseRenderData
Calendar *caldav.Calendar
Event CalendarObject
}
type UpdateEventRenderData struct {
alps.BaseRenderData
Calendar *caldav.Calendar
CalendarObject *caldav.CalendarObject // nil if creating a new contact
Event *ical.Event
}
const (
monthPageLayout = "2006-01"
datePageLayout = "2006-01-02"
)
func parseObjectPath(s string) (string, error) {
p, err := url.PathUnescape(s)
if err != nil {
err = fmt.Errorf("failed to parse path: %v", err)
return "", echo.NewHTTPError(http.StatusBadRequest, err)
}
return string(p), nil
}
func parseTime(dateStr, timeStr string) (time.Time, error) {
layout := inputDateLayout
s := dateStr
if timeStr != "" {
layout = inputDateLayout + "T" + inputTimeLayout
s = dateStr + "T" + timeStr
}
t, err := time.Parse(layout, s)
if err != nil {
err = fmt.Errorf("malformed date: %v", err)
return time.Time{}, echo.NewHTTPError(http.StatusBadRequest, err)
}
return t, nil
}
func registerRoutes(p *alps.GoPlugin, u *url.URL) {
p.GET("/calendar", func(ctx *alps.Context) error {
var start time.Time
if s := ctx.QueryParam("month"); s != "" {
var err error
start, err = time.Parse(monthPageLayout, s)
if err != nil {
return fmt.Errorf("failed to parse month: %v", err)
}
} else {
now := time.Now()
start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
}
end := start.AddDate(0, 1, 0)
// TODO: multi-calendar support
c, calendar, err := getCalendar(u, ctx.Session)
if err != nil {
return err
}
query := caldav.CalendarQuery{
CompRequest: caldav.CalendarCompRequest{
Name: "VCALENDAR",
Props: []string{"VERSION"},
Comps: []caldav.CalendarCompRequest{{
Name: "VEVENT",
Props: []string{
"SUMMARY",
"UID",
"DTSTART",
"DTEND",
"DURATION",
},
}},
},
CompFilter: caldav.CompFilter{
Name: "VCALENDAR",
Comps: []caldav.CompFilter{{
Name: "VEVENT",
Start: start,
End: end,
}},
},
}
events, err := c.QueryCalendar(calendar.Path, &query)
if err != nil {
return fmt.Errorf("failed to query calendar: %v", err)
}
// TODO: Time zones are hard
var dates [7 * 6]time.Time
initialDate := start.UTC()
initialDate = initialDate.AddDate(0, 0, -int(initialDate.Weekday()))
for i := 0; i < len(dates); i += 1 {
dates[i] = initialDate
initialDate = initialDate.AddDate(0, 0, 1)
}
eventMap := make(map[time.Time][]CalendarObject)
for _, ev := range events {
ev := ev // make a copy
// TODO: include event on each date for which it is active
co := ev.Data.Events()[0]
startTime, _ := co.DateTimeStart(nil)
startTime = startTime.UTC().Truncate(time.Hour * 24)
eventMap[startTime] = append(eventMap[startTime], CalendarObject{&ev})
}
return ctx.Render(http.StatusOK, "calendar.html", &CalendarRenderData{
BaseRenderData: *alps.NewBaseRenderData(ctx).
WithTitle(calendar.Name + " Calendar: " + start.Format("January 2006")),
Time: start,
Now: time.Now(), // TODO: Use client time zone
Calendar: calendar,
Dates: dates,
Events: newCalendarObjectList(events),
PrevPage: start.AddDate(0, -1, 0).Format(monthPageLayout),
NextPage: start.AddDate(0, 1, 0).Format(monthPageLayout),
PrevTime: start.AddDate(0, -1, 0),
NextTime: start.AddDate(0, 1, 0),
EventsForDate: func(when time.Time) []CalendarObject {
if events, ok := eventMap[when.Truncate(time.Hour*24)]; ok {
return events
}
return nil
},
DaySuffix: func(n int) string {
if n%100 >= 11 && n%100 <= 13 {
return "th"
}
return map[int]string{
0: "th",
1: "st",
2: "nd",
3: "rd",
4: "th",
5: "th",
6: "th",
7: "th",
8: "th",
9: "th",
}[n%10]
},
Sub: func(a, b int) int {
// Why isn't this built-in, come on Go
return a - b
},
})
})
p.GET("/calendar/date", func(ctx *alps.Context) error {
var start time.Time
if s := ctx.QueryParam("date"); s != "" {
var err error
start, err = time.Parse(datePageLayout, s)
if err != nil {
return fmt.Errorf("failed to parse date: %v", err)
}
} else {
now := time.Now()
start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
}
end := start.AddDate(0, 0, 1)
// TODO: multi-calendar support
c, calendar, err := getCalendar(u, ctx.Session)
if err != nil {
return err
}
query := caldav.CalendarQuery{
CompRequest: caldav.CalendarCompRequest{
Name: "VCALENDAR",
Props: []string{"VERSION"},
Comps: []caldav.CalendarCompRequest{{
Name: "VEVENT",
Props: []string{
"SUMMARY",
"UID",
"DTSTART",
"DTEND",
"DURATION",
},
}},
},
CompFilter: caldav.CompFilter{
Name: "VCALENDAR",
Comps: []caldav.CompFilter{{
Name: "VEVENT",
Start: start,
End: end,
}},
},
}
events, err := c.QueryCalendar(calendar.Path, &query)
if err != nil {
return fmt.Errorf("failed to query calendar: %v", err)
}
return ctx.Render(http.StatusOK, "calendar-date.html", &CalendarDateRenderData{
BaseRenderData: *alps.NewBaseRenderData(ctx).
WithTitle(calendar.Name + " Calendar: " + start.Format("January 02, 2006")),
Time: start,
Events: newCalendarObjectList(events),
Calendar: calendar,
PrevPage: start.AddDate(0, 0, -1).Format(datePageLayout),
NextPage: start.AddDate(0, 0, 1).Format(datePageLayout),
})
})
p.GET("/calendar/:path", func(ctx *alps.Context) error {
path, err := parseObjectPath(ctx.Param("path"))
if err != nil {
return err
}
c, calendar, err := getCalendar(u, ctx.Session)
if err != nil {
return err
}
multiGet := caldav.CalendarMultiGet{
CompRequest: caldav.CalendarCompRequest{
Name: "VCALENDAR",
Props: []string{"VERSION"},
Comps: []caldav.CalendarCompRequest{{
Name: "VEVENT",
Props: []string{
"SUMMARY",
"DESCRIPTION",
"UID",
"DTSTART",
"DTEND",
"DURATION",
},
}},
},
}
events, err := c.MultiGetCalendar(path, &multiGet)
if err != nil {
return fmt.Errorf("failed to multi-get calendar: %v", err)
}
if len(events) != 1 {
return fmt.Errorf("expected exactly one calendar object with path %q, got %v", path, len(events))
}
event := &events[0]
summary, _ := event.Data.Events()[0].Props.Text("SUMMARY")
return ctx.Render(http.StatusOK, "event.html", &EventRenderData{
BaseRenderData: *alps.NewBaseRenderData(ctx).WithTitle(summary),
Calendar: calendar,
Event: CalendarObject{event},
})
})
updateEvent := func(ctx *alps.Context) error {
calendarObjectPath, err := parseObjectPath(ctx.Param("path"))
if err != nil {
return err
}
c, calendar, err := getCalendar(u, ctx.Session)
if err != nil {
return err
}
var co *caldav.CalendarObject
var event *ical.Event
if calendarObjectPath != "" {
co, err = c.GetCalendarObject(calendarObjectPath)
if err != nil {
return fmt.Errorf("failed to get CalDAV event: %v", err)
}
events := co.Data.Events()
if len(events) != 1 {
return fmt.Errorf("expected exactly one event, got %d", len(events))
}
event = &events[0]
} else {
event = ical.NewEvent()
}
if ctx.Request().Method == "POST" {
summary := ctx.FormValue("summary")
description := ctx.FormValue("description")
// TODO: whole-day events
start, err := parseTime(ctx.FormValue("start-date"), ctx.FormValue("start-time"))
if err != nil {
return err
}
end, err := parseTime(ctx.FormValue("end-date"), ctx.FormValue("end-time"))
if err != nil {
return err
}
if start.After(end) {
return echo.NewHTTPError(http.StatusBadRequest, "event start is after its end")
}
if start == end {
end = start.Add(24 * time.Hour)
}
event.Props.SetDateTime(ical.PropDateTimeStamp, time.Now())
event.Props.SetText(ical.PropSummary, summary)
event.Props.SetDateTime(ical.PropDateTimeStart, start)
event.Props.SetDateTime(ical.PropDateTimeEnd, end)
event.Props.Del(ical.PropDuration)
if description != "" {
description = strings.ReplaceAll(description, "\r", "")
event.Props.SetText(ical.PropDescription, description)
} else {
event.Props.Del(ical.PropDescription)
}
newID := uuid.New()
if prop := event.Props.Get(ical.PropUID); prop == nil {
event.Props.SetText(ical.PropUID, newID.String())
}
cal := ical.NewCalendar()
cal.Props.SetText(ical.PropProductID, "-//emersion.fr//alps//EN")
cal.Props.SetText(ical.PropVersion, "2.0")
cal.Children = append(cal.Children, event.Component)
var p string
if co != nil {
p = co.Path
} else {
p = path.Join(calendar.Path, newID.String()+".ics")
}
co, err = c.PutCalendarObject(p, cal)
if err != nil {
return fmt.Errorf("failed to put calendar object: %v", err)
}
return ctx.Redirect(http.StatusFound, CalendarObject{co}.URL())
}
summary, _ := event.Props.Text("SUMMARY")
return ctx.Render(http.StatusOK, "update-event.html", &UpdateEventRenderData{
BaseRenderData: *alps.NewBaseRenderData(ctx).WithTitle("Update " + summary),
Calendar: calendar,
CalendarObject: co,
Event: event,
})
}
p.GET("/calendar/create", updateEvent)
p.POST("/calendar/create", updateEvent)
p.GET("/calendar/:path/update", updateEvent)
p.POST("/calendar/:path/update", updateEvent)
p.POST("/calendar/:path/delete", func(ctx *alps.Context) error {
path, err := parseObjectPath(ctx.Param("path"))
if err != nil {
return err
}
c, _, err := getCalendar(u, ctx.Session)
if err != nil {
return err
}
if err := c.RemoveAll(path); err != nil {
return fmt.Errorf("failed to delete calendar object: %v", err)
}
return ctx.Redirect(http.StatusFound, "/calendar")
})
}