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
+48 -10
View File
@@ -84,12 +84,14 @@ func (b *Backend) ListCalendars(ctx context.Context) ([]caldav.Calendar, error)
return nil, webdav.NewHTTPError(http.StatusUnauthorized, fmt.Errorf("not authenticated"))
}
// The synthetic Birthdays calendar is always present, computed from
// the requester's own contacts.
names, err := b.dbase.ListCalendars(p.Username)
if err != nil {
return nil, fmt.Errorf("listing calendars: %w", err)
}
var cals []caldav.Calendar
cals := []caldav.Calendar{b.birthdaysCalendarMeta(p.Username)}
for _, cal := range names {
if err := b.store.EnsureCollection(p.Username, "cal-"+cal.Name); err != nil {
b.logger.Warn("ensuring calendar directory", "calendar", cal.Name, "error", err)
@@ -134,6 +136,10 @@ func (b *Backend) GetCalendar(ctx context.Context, calPath string) (*caldav.Cale
if err != nil {
return nil, err
}
if localName == birthdaysCalendarName {
cal := b.birthdaysCalendarMeta(requester)
return &cal, nil
}
owner, realName, _, err := b.resolveCalendar(requester, localName, false)
if err != nil {
return nil, err
@@ -150,6 +156,9 @@ func (b *Backend) GetCalendarObject(ctx context.Context, objPath string, req *ca
if err != nil {
return nil, err
}
if localName == birthdaysCalendarName {
return b.birthdayCalendarObject(requester, objPath, objID)
}
owner, realName, _, err := b.resolveCalendar(requester, localName, false)
if err != nil {
return nil, err
@@ -168,6 +177,9 @@ func (b *Backend) ListCalendarObjects(ctx context.Context, calPath string, req *
if err != nil {
return nil, err
}
if localName == birthdaysCalendarName {
return b.listBirthdayCalendarObjects(requester)
}
owner, realName, _, err := b.resolveCalendar(requester, localName, false)
if err != nil {
return nil, err
@@ -213,8 +225,13 @@ func (b *Backend) CreateCalendar(ctx context.Context, calendar *caldav.Calendar)
// existing calendar is done via ShareCalendar, not by creating one
// directly in someone else's name.
name := path.Base(strings.TrimSuffix(calendar.Path, "/"))
if err := b.dbase.CreateCalendar(p.Username, name); err != nil && err != db.ErrResourceExists {
return fmt.Errorf("registering calendar: %w", err)
if err := b.dbase.CreateCalendar(p.Username, name); err != nil {
if err == db.ErrReservedName {
return webdav.NewHTTPError(http.StatusForbidden, err)
}
if err != db.ErrResourceExists {
return fmt.Errorf("registering calendar: %w", err)
}
}
return b.store.EnsureCollection(p.Username, "cal-"+name)
}
@@ -224,6 +241,9 @@ func (b *Backend) DeleteCalendar(ctx context.Context, calPath string) error {
if err != nil {
return err
}
if localName == birthdaysCalendarName {
return webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("the birthdays calendar is read-only and computed automatically"))
}
owner, realName, _, err := b.resolveCalendar(requester, localName, true)
if err != nil {
return err
@@ -239,6 +259,9 @@ func (b *Backend) PutCalendarObject(ctx context.Context, objPath string, calenda
if err != nil {
return nil, err
}
if localName == birthdaysCalendarName {
return nil, webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("the birthdays calendar is read-only and computed automatically"))
}
owner, realName, _, err := b.resolveCalendar(requester, localName, true)
if err != nil {
return nil, err
@@ -263,6 +286,9 @@ func (b *Backend) DeleteCalendarObject(ctx context.Context, objPath string) erro
if err != nil {
return err
}
if localName == birthdaysCalendarName {
return webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("the birthdays calendar is read-only and computed automatically"))
}
owner, realName, _, err := b.resolveCalendar(requester, localName, true)
if err != nil {
return err
@@ -495,13 +521,25 @@ func (h *colorInjectingHandler) injectCalendarColors(ctx context.Context, body [
return block
}
localName := strings.TrimSuffix(strings.TrimPrefix(href, calHomePath()), "/")
owner, realName, _, err := h.backend.resolveCalendar(p.Username, localName, false)
if err != nil {
return block
}
color, err := h.backend.dbase.GetCalendarColor(owner, realName)
if err != nil || color == "" {
return block
var color string
var err error
if localName == birthdaysCalendarName {
color, err = h.backend.dbase.GetBirthdayCalendarColor(p.Username)
if err != nil {
color = ""
}
if color == "" {
color = defaultBirthdayColor
}
} else {
owner, realName, _, rerr := h.backend.resolveCalendar(p.Username, localName, false)
if rerr != nil {
return block
}
color, err = h.backend.dbase.GetCalendarColor(owner, realName)
if err != nil || color == "" {
return block
}
}
// Drop any propstat block that only complains calendar-color is