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:
+48
-10
@@ -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
|
||||
|
||||
@@ -209,8 +209,14 @@ func TestPropFindEmitsCalendarColor(t *testing.T) {
|
||||
// Find personal's response block boundaries loosely by looking for the
|
||||
// nearest calendar-color occurrence and ensuring it isn't right next to
|
||||
// the personal href (colors are per-block, checked via count instead).
|
||||
if strings.Count(body, "<calendar-color ") != 1 {
|
||||
t.Fatalf("expected exactly one calendar-color element (only for work), got: %s", body)
|
||||
// Two calendar-color elements are expected: one for "work" (explicit
|
||||
// color) and one for the always-present synthetic "birthdays" calendar
|
||||
// (default color, since it wasn't explicitly set here).
|
||||
if strings.Count(body, "<calendar-color ") != 2 {
|
||||
t.Fatalf("expected exactly two calendar-color elements (work + birthdays default), got: %s", body)
|
||||
}
|
||||
if !strings.Contains(body, `<calendar-color xmlns="http://apple.com/ns/ical/">`+defaultBirthdayColor+`FF</calendar-color>`) {
|
||||
t.Fatalf("expected default birthdays calendar-color, got: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user