Add virtual Birthdays calendar computed from contacts
The combined month view now always includes a read-only "Birthdays" calendar (ref "@birthdays"), generated on the fly from every contact's BDAY field across the user's address books — no calendar objects are stored for it. Each birthday appears as an all-day event titled "🎂 Name (Age)" (age omitted if BDAY has no year, e.g. "--MM-DD"), colored pink, and links to the contact's edit page instead of an event editor. Handles both "YYYY-MM-DD"/"YYYYMMDD" and year-less vCard BDAY formats, and skips Feb 29 birthdays in non-leap years. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+175
-5
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user