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
+156
View File
@@ -0,0 +1,156 @@
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"
"github.com/yourusername/caldav-server/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
// "<book>~<contact-id-without-ext>~<year>.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
}