Make birthdays calendar color configurable and expose it via CalDAV

- Add a per-user birthday_calendars table (color, cascade-deletes with
  the user) and GetBirthdayCalendarColor/SetBirthdayCalendarColor in
  internal/db, plus a reservedCalendarNames guard ("birthdays") in
  CreateCalendarWithColor so no real calendar can collide with the
  synthetic one, whether created via the web UI, nidusctl, or CalDAV
  MKCALENDAR.
- Add a "Birthdays" virtual resource card to the dashboard (color
  picker only, no delete/share controls) backed by a new
  ResourceCard.Virtual flag and POST /web/resources/birthdays/color
  handler.
- Extract the birthday-parsing/generation logic shared by the web
  calendar view and CalDAV into internal/birthdays (ParseBirthday,
  Collect, OccurrenceDate, Summary) instead of duplicating it.
- Expose the Birthdays calendar over real CalDAV in
  internal/caldav/backend.go + birthdays.go: it's always listed for
  every user, generates one VEVENT per (contact, year) for a rolling
  window (current year -2..+8) with "🎂 Name (Age)" titles, is
  read-only (Put/Delete/DeleteCalendar all return 403), and its
  Apple/DAVx5 calendar-color is injected from the same per-user
  setting used by the dashboard/web view.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-08-20 20:09:59 +02:00
co-authored by Copilot
parent cd96b365d0
commit b451f1a76e
12 changed files with 753 additions and 353 deletions
+20 -103
View File
@@ -16,7 +16,7 @@ import (
"time"
ical "github.com/emersion/go-ical"
vcard "github.com/emersion/go-vcard"
"github.com/yourusername/caldav-server/internal/birthdays"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/web/templates"
)
@@ -133,9 +133,20 @@ type calendarEntry struct {
// 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"
// defaultBirthdayColor is the display color for the virtual birthdays
// calendar used until the user picks their own from the dashboard (a
// pink, distinct from typical user-picked calendar colors).
const defaultBirthdayColor = "#ec4899"
// birthdayCalendarColor returns username's chosen color for the virtual
// birthdays calendar, falling back to defaultBirthdayColor if unset.
func (s *Server) birthdayCalendarColor(username string) string {
color, err := s.dbase.GetBirthdayCalendarColor(username)
if err != nil || color == "" {
return defaultBirthdayColor
}
return color
}
// listCalendarEntries returns every calendar visible to username: the
// synthetic birthdays calendar, their own calendars (always writable),
@@ -143,7 +154,7 @@ const birthdayColor = "#ec4899"
// write permission).
func (s *Server) listCalendarEntries(username string) ([]calendarEntry, error) {
entries := []calendarEntry{
{Ref: birthdaysCalRef, Owner: username, Name: "Birthdays", Color: birthdayColor, Writable: false, Virtual: true},
{Ref: birthdaysCalRef, Owner: username, Name: "Birthdays", Color: s.birthdayCalendarColor(username), Writable: false, Virtual: true},
}
own, err := s.dbase.ListCalendars(username)
@@ -973,100 +984,13 @@ 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)
contacts, err := birthdays.Collect(s.store, s.dbase, username)
if err != nil {
return err
}
@@ -1084,11 +1008,8 @@ func (s *Server) addBirthdayEvents(username string, entry calendarEntry, gridSta
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 {
occurrence, ok := birthdays.OccurrenceDate(c, year, gridStart.Location())
if !ok {
continue
}
if occurrence.Before(gridStart) || occurrence.After(gridEnd) {
@@ -1098,15 +1019,11 @@ func (s *Server) addBirthdayEvents(username string, entry calendarEntry, gridSta
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,
Summary: birthdays.Summary(c, year),
AllDay: true,
LinkURL: "/web/contacts/" + c.Book + "/" + c.ID + "/edit",
})