aboutsummaryrefslogblamecommitdiff
path: root/plugins/caldav/routes.go
blob: f1baa1ee65dd62dc310a8f7cb62ce58b913a7284 (plain) (tree)
1
2
3
4
5
6
7
8
9
10
                  




                  
              
                 

              
                                
                                     
                                              
                                
                                     


                                
                           
                                    

                                           
                                           
                                           
                                 




                                                      

 







                                           
                             
                           
                                 
                               

 

                                   
                                       



                                                                              



                                      
 





                                                                        
                     

 














                                                                                 

                                                          












                                                                                                 





                                                               




























                                                                              


















                                                                                              
                                                                                      

                                                                                                        








                                                                                  

                                                                              
                                                                                            





                                                       
                                                               












                                                      
                                       

                          
                                                 


                                                                      

                  
 



























































                                                                                                            





                                                                




                                                               
                                                    














                                                                     
                 

                                                                  
                               
                                                                                  

                                     
                                                                                                                         

                                   
                                                                          

                                                                                
                                                                                        
                                                 
                                                              

                  






                                                                             







                                                               
                                                                         













                                                                                                    

                                                                   

                                                                                                         
                                       
                                          
                         
                                                                                                   
                                       
                                          














                                                                                                               






                                                                                       























                                                                                           

                                                         
                                                                                             
                                                                                                    
                                                 









                                                     

















                                                                                      
 
package alpscaldav

import (
	"fmt"
	"net/http"
	"net/url"
	"path"
	"strings"
	"time"

	"git.sr.ht/~migadu/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 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")
	})
}