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") }) }