diff --git a/internal/web/calendar.go b/internal/web/calendar.go index fb06b0c..615e63d 100644 --- a/internal/web/calendar.go +++ b/internal/web/calendar.go @@ -16,6 +16,7 @@ import ( "time" ical "github.com/emersion/go-ical" + vcard "github.com/emersion/go-vcard" "github.com/yourusername/caldav-server/internal/db" "github.com/yourusername/caldav-server/internal/web/templates" ) @@ -82,6 +83,11 @@ func (s *Server) ownsCalendar(username, cal string) (bool, error) { // store.Store/db.DB. If requireWrite is true, a read-only share is // rejected with errCalendarReadOnly. func (s *Server) resolveCalRef(username, ref string, requireWrite bool) (owner, name string, err error) { + if ref == birthdaysCalRef { + // The birthdays calendar is virtual/computed, not backed by any + // stored calendar object โ there's nothing to resolve to. + return "", "", errCalendarNotFound + } if owner, name, ok := strings.Cut(ref, calRefSep); ok { if !resourceNameRe.MatchString(owner) || !resourceNameRe.MatchString(name) { return "", "", errCalendarNotFound @@ -117,13 +123,28 @@ type calendarEntry struct { Name string // calendar's own name (unqualified) Color string Writable bool + Virtual bool // true for computed calendars (e.g. birthdays) with no backing store objects } -// listCalendarEntries returns every calendar visible to username: their -// own calendars (always writable) plus any calendars shared with them -// (writable only if the share grants write permission). +// birthdaysCalRef is the fixed reference for the synthetic "Birthdays" +// calendar. It deliberately can't collide with a real calendar's ref: "@" +// is not in resourceNameRe's character class (so it can't be a bare own +// calendar name) and it contains no calRefSep ("~") (so it can't be +// mistaken for an "owner~name" shared-calendar ref either). +const birthdaysCalRef = "@birthdays" + +// birthdayColor is the fixed display color for the virtual birthdays +// calendar (a pink, distinct from typical user-picked calendar colors). +const birthdayColor = "#ec4899" + +// listCalendarEntries returns every calendar visible to username: the +// synthetic birthdays calendar, their own calendars (always writable), +// and any calendars shared with them (writable only if the share grants +// write permission). func (s *Server) listCalendarEntries(username string) ([]calendarEntry, error) { - var entries []calendarEntry + entries := []calendarEntry{ + {Ref: birthdaysCalRef, Owner: username, Name: "Birthdays", Color: birthdayColor, Writable: false, Virtual: true}, + } own, err := s.dbase.ListCalendars(username) if err != nil { @@ -241,9 +262,16 @@ func (s *Server) buildMonthView(username string, year int, month time.Month) (te } calSummaries = append(calSummaries, templates.CalendarSummary{ Ref: entry.Ref, Name: entry.Name, Owner: entry.Owner, Color: entry.Color, - Shared: entry.Owner != username, Writable: entry.Writable, + Shared: entry.Owner != username, Writable: entry.Writable, Virtual: entry.Virtual, }) + if entry.Ref == birthdaysCalRef { + if err := s.addBirthdayEvents(username, entry, gridStart, gridEnd, dayIndex, days); err != nil { + s.logger.Warn("computing birthday events", "error", err) + } + continue + } + ids, err := s.store.ListObjects(entry.Owner, "cal-"+entry.Name) if err != nil { continue @@ -944,3 +972,145 @@ func (s *Server) handleCalendarImport(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/web/calendar?imported="+strconv.Itoa(imported), http.StatusSeeOther) } + +// birthdayContact holds one contact's parsed birthday, ready to be placed +// on the month grid. +type birthdayContact struct { + Name string + Book string + ID string + Month time.Month + Day int + Year int // 0 if the vCard's BDAY has no year (e.g. "--08-20") +} + +// birthdayFullRe matches a BDAY value that includes a year, in either +// "YYYY-MM-DD" or the older vCard 3.0 "YYYYMMDD" form. +var birthdayFullRe = regexp.MustCompile(`^(\d{4})-?(\d{2})-?(\d{2})`) + +// birthdayNoYearRe matches a year-less BDAY value per RFC 6350 ยง4.3.1, +// "--MM-DD" or "--MMDD". +var birthdayNoYearRe = regexp.MustCompile(`^--(\d{2})-?(\d{2})`) + +// parseBirthday extracts month/day (and year, if present) from a vCard +// BDAY field value. Returns ok=false if v isn't a recognized date format +// or names an impossible month/day. +func parseBirthday(v string) (month time.Month, day int, year int, ok bool) { + v = strings.TrimSpace(v) + if m := birthdayNoYearRe.FindStringSubmatch(v); m != nil { + mo, _ := strconv.Atoi(m[1]) + d, _ := strconv.Atoi(m[2]) + if mo < 1 || mo > 12 || d < 1 || d > 31 { + return 0, 0, 0, false + } + return time.Month(mo), d, 0, true + } + if m := birthdayFullRe.FindStringSubmatch(v); m != nil { + y, _ := strconv.Atoi(m[1]) + mo, _ := strconv.Atoi(m[2]) + d, _ := strconv.Atoi(m[3]) + if mo < 1 || mo > 12 || d < 1 || d > 31 { + return 0, 0, 0, false + } + return time.Month(mo), d, y, true + } + return 0, 0, 0, false +} + +// collectBirthdays scans every one of username's own address books for +// contacts with a parseable BDAY field. +func (s *Server) collectBirthdays(username string) ([]birthdayContact, error) { + books, err := s.dbase.ListAddressBooks(username) + if err != nil { + return nil, err + } + + var contacts []birthdayContact + for _, book := range books { + ids, err := s.store.ListObjects(username, "card-"+book) + if err != nil { + continue + } + for _, id := range ids { + data, err := s.store.GetObject(username, "card-"+book, id) + if err != nil { + continue + } + card, err := vcard.NewDecoder(bytes.NewReader(data)).Decode() + if err != nil { + continue + } + bday := card.PreferredValue(vcard.FieldBirthday) + if bday == "" { + continue + } + month, day, year, ok := parseBirthday(bday) + if !ok { + continue + } + name := card.PreferredValue(vcard.FieldFormattedName) + if name == "" { + name = id + } + contacts = append(contacts, birthdayContact{ + Name: name, Book: book, ID: id, Month: month, Day: day, Year: year, + }) + } + } + return contacts, nil +} + +// addBirthdayEvents places one virtual all-day event per contact +// birthday falling within [gridStart, gridEnd] into days, titled +// "๐ Name" (or "๐ Name (Age)" when the birth year is known). Each event +// links to the contact's edit page instead of an event edit page, since +// there is no underlying calendar object to edit. +func (s *Server) addBirthdayEvents(username string, entry calendarEntry, gridStart, gridEnd time.Time, dayIndex map[string]int, days []templates.MonthDay) error { + contacts, err := s.collectBirthdays(username) + if err != nil { + return err + } + if len(contacts) == 0 { + return nil + } + + // The 42-day grid can span at most two calendar years (e.g. a + // December/January boundary), so only those years' occurrences need + // checking. + years := []int{gridStart.Year()} + if gridEnd.Year() != gridStart.Year() { + years = append(years, gridEnd.Year()) + } + + for _, c := range contacts { + for _, year := range years { + occurrence := time.Date(year, c.Month, c.Day, 0, 0, 0, 0, gridStart.Location()) + // Guard against date normalization (e.g. Feb 29 in a + // non-leap year rolling over into March) placing the event + // on the wrong day. + if occurrence.Month() != c.Month || occurrence.Day() != c.Day { + continue + } + if occurrence.Before(gridStart) || occurrence.After(gridEnd) { + continue + } + idx, ok := dayIndex[occurrence.Format(dateLayout)] + if !ok { + continue + } + summary := "๐ " + c.Name + if c.Year > 0 { + summary += fmt.Sprintf(" (%d)", year-c.Year) + } + days[idx].Events = append(days[idx].Events, templates.EventSummary{ + ID: c.Book + "/" + c.ID, + CalRef: entry.Ref, + Color: entry.Color, + Summary: summary, + AllDay: true, + LinkURL: "/web/contacts/" + c.Book + "/" + c.ID + "/edit", + }) + } + } + return nil +} diff --git a/internal/web/templates/calendar.templ b/internal/web/templates/calendar.templ index 81ef5e6..f2078a9 100644 --- a/internal/web/templates/calendar.templ +++ b/internal/web/templates/calendar.templ @@ -11,6 +11,7 @@ type CalendarSummary struct { Color string // hex color like "#3b82f6", "" if unset Shared bool // true if owned by someone other than the viewer Writable bool + Virtual bool // true for computed calendars (e.g. birthdays) with no import/export } // EventSummary is a single event shown inside a month-view day cell. @@ -21,6 +22,10 @@ type EventSummary struct { Summary string TimeText string // e.g. "14:00" or "" for all-day events AllDay bool + // LinkURL overrides the default "/web/calendar/{CalRef}/{ID}/edit" + // link target, used by virtual/read-only calendars (e.g. birthdays) + // that don't have an editable event object of their own. + LinkURL string } // MonthDay is one day cell in the month grid. @@ -123,7 +128,9 @@ templ calendarLegendRow(c CalendarSummary) { (read-only) } - Export .ics + if !c.Virtual { + Export .ics + } if c.Writable {
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" enctype=\"multipart/form-data\" class=\"flex items-center gap-1\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -383,7 +394,7 @@ func monthDayCell(day MonthDay) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var29 string templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(errMsg) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/calendar.templ`, Line: 175, Col: 98} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/calendar.templ`, Line: 191, Col: 98} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if data.ID != "" && !data.Writable { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "This calendar was shared with you as read-only โ you can view this event but not change it.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "This calendar was shared with you as read-only โ you can view this event but not change it.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }