- 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>
157 lines
5.3 KiB
Go
157 lines
5.3 KiB
Go
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
|
|
}
|