package caldav import ( "fmt" "net/http" "path" "regexp" "strconv" "strings" "time" ical "github.com/emersion/go-ical" "github.com/emersion/go-webdav" "github.com/emersion/go-webdav/caldav" "git.arnef.de/arnef/nidus/internal/birthdays" ) // birthdaysCalendarName is the fixed, reserved local calendar name the // synthetic Birthdays calendar is exposed under (see // db.reservedCalendarNames, which prevents a real calendar from ever // colliding with it). It has no owner/shared-name segment: it's always // computed from the requester's own contacts. const birthdaysCalendarName = "birthdays" // defaultBirthdayColor is the display color for the virtual birthdays // calendar when the user hasn't chosen one, matching internal/web's. const defaultBirthdayColor = "#ec4899" // birthdayWindowPast/birthdayWindowFuture bound how many years around the // current one the synthetic Birthdays calendar generates events for. // CalDAV clients sync ahead of time (unlike the web view, which only // computes the month currently being browsed), so a fixed window is // generated upfront instead. const birthdayWindowPast = 2 const birthdayWindowFuture = 8 // birthdayObjIDRe parses an object ID of the form // "~~.ics" back into its parts. var birthdayObjIDRe = regexp.MustCompile(`^([^~]+)~([^~]+)~(\d{4})\.ics$`) // birthdayObjID builds the stable object ID for contact c's birthday // occurrence in year, encoding enough information to regenerate that // exact VEVENT on demand in GetCalendarObject. func birthdayObjID(c birthdays.Contact, year int) string { base := strings.TrimSuffix(c.ID, path.Ext(c.ID)) return fmt.Sprintf("%s~%s~%d.ics", c.Book, base, year) } // birthdaysCalendarMeta returns the caldav.Calendar metadata for // username's synthetic Birthdays calendar. func (b *Backend) birthdaysCalendarMeta(username string) caldav.Calendar { return caldav.Calendar{ Path: calHomePath() + birthdaysCalendarName + "/", Name: "Birthdays", Description: "Birthdays computed from your contacts", SupportedComponentSet: []string{"VEVENT"}, MaxResourceSize: 64 * 1024, } } // listBirthdayCalendarObjects generates one VEVENT per (contact, year) // pair within the fixed rolling window for every contact of username's // that has a parseable birthday. func (b *Backend) listBirthdayCalendarObjects(username string) ([]caldav.CalendarObject, error) { contacts, err := birthdays.Collect(b.store, b.dbase, username) if err != nil { return nil, fmt.Errorf("collecting birthdays: %w", err) } now := time.Now() var objs []caldav.CalendarObject for _, c := range contacts { for year := now.Year() - birthdayWindowPast; year <= now.Year()+birthdayWindowFuture; year++ { obj, ok := b.encodeBirthdayObject(c, year) if !ok { continue } objs = append(objs, *obj) } } return objs, nil } // birthdayCalendarObject regenerates a single VEVENT for the (contact, // year) pair encoded in objID. func (b *Backend) birthdayCalendarObject(username, objPath, objID string) (*caldav.CalendarObject, error) { m := birthdayObjIDRe.FindStringSubmatch(objID) if m == nil { return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("unrecognized birthday object id")) } book, idBase, year := m[1], m[2], m[3] yearNum, err := strconv.Atoi(year) if err != nil { return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("invalid year")) } contacts, err := birthdays.Collect(b.store, b.dbase, username) if err != nil { return nil, fmt.Errorf("collecting birthdays: %w", err) } for _, c := range contacts { if c.Book != book { continue } if strings.TrimSuffix(c.ID, path.Ext(c.ID)) != idBase { continue } obj, ok := b.encodeBirthdayObject(c, yearNum) if !ok { break } obj.Path = objPath return obj, nil } return nil, webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("birthday event not found")) } // encodeBirthdayObject builds the all-day VEVENT for contact c's birthday // occurrence in year, returning ok=false if that (month, day) combination // doesn't exist in year (e.g. Feb 29 in a non-leap year). func (b *Backend) encodeBirthdayObject(c birthdays.Contact, year int) (*caldav.CalendarObject, bool) { start, ok := birthdays.OccurrenceDate(c, year, time.UTC) if !ok { return nil, false } end := start.AddDate(0, 0, 1) event := ical.NewEvent() uid := birthdayObjID(c, year) event.Props.SetText(ical.PropUID, uid) event.Props.SetText(ical.PropSummary, birthdays.Summary(c, year)) event.Props.SetDate(ical.PropDateTimeStart, start) event.Props.SetDate(ical.PropDateTimeEnd, end) event.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC()) event.Props.SetText(ical.PropTransparency, "TRANSPARENT") cal := ical.NewCalendar() cal.Props.SetText(ical.PropVersion, "2.0") cal.Props.SetText(ical.PropProductID, "-//nidus//birthdays//EN") cal.Children = append(cal.Children, event.Component) var buf strings.Builder if err := ical.NewEncoder(&buf).Encode(cal); err != nil { return nil, false } objID := birthdayObjID(c, year) return &caldav.CalendarObject{ Path: calObjectPath(birthdaysCalendarName, objID), ModTime: time.Now(), ContentLength: int64(buf.Len()), ETag: fmt.Sprintf(`"bday-%s-%d"`, strings.TrimSuffix(c.ID, path.Ext(c.ID)), year), Data: cal, }, true }