diff --git a/internal/birthdays/birthdays.go b/internal/birthdays/birthdays.go new file mode 100644 index 0000000..b3a361b --- /dev/null +++ b/internal/birthdays/birthdays.go @@ -0,0 +1,126 @@ +// Package birthdays computes virtual "birthday events" from a user's own +// contacts, shared by the web calendar UI (internal/web) and the CalDAV +// backend (internal/caldav) so both present identical titles/dates for the +// synthetic Birthdays calendar without duplicating the parsing logic. +package birthdays + +import ( + "bytes" + "fmt" + "regexp" + "strconv" + "strings" + "time" + + "github.com/emersion/go-vcard" + + "github.com/yourusername/caldav-server/internal/db" + "github.com/yourusername/caldav-server/internal/store" +) + +// Contact holds one contact's parsed birthday. +type Contact 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") +} + +// fullRe matches a BDAY value that includes a year, in either +// "YYYY-MM-DD" or the older vCard 3.0 "YYYYMMDD" form. +var fullRe = regexp.MustCompile(`^(\d{4})-?(\d{2})-?(\d{2})`) + +// noYearRe matches a year-less BDAY value per RFC 6350 ยง4.3.1, +// "--MM-DD" or "--MMDD". +var noYearRe = 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 := noYearRe.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 := fullRe.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 +} + +// Collect scans every one of username's own address books for contacts +// with a parseable BDAY field. +func Collect(st *store.Store, dbase *db.DB, username string) ([]Contact, error) { + books, err := dbase.ListAddressBooks(username) + if err != nil { + return nil, err + } + + var contacts []Contact + for _, book := range books { + ids, err := st.ListObjects(username, "card-"+book) + if err != nil { + continue + } + for _, id := range ids { + data, err := st.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, Contact{ + Name: name, Book: book, ID: id, Month: month, Day: day, Year: year, + }) + } + } + return contacts, nil +} + +// OccurrenceDate returns the date c's birthday falls on in year, and false +// if that combination doesn't exist (e.g. Feb 29 in a non-leap year, +// where Go's time.Date would otherwise silently normalize to March 1/2). +func OccurrenceDate(c Contact, year int, loc *time.Location) (time.Time, bool) { + occ := time.Date(year, c.Month, c.Day, 0, 0, 0, 0, loc) + if occ.Month() != c.Month || occ.Day() != c.Day { + return time.Time{}, false + } + return occ, true +} + +// Summary formats c's virtual event title for the given year: +// "๐ŸŽ‚ Name", or "๐ŸŽ‚ Name (Age)" if c's birth year is known. +func Summary(c Contact, year int) string { + s := "๐ŸŽ‚ " + c.Name + if c.Year > 0 { + s += fmt.Sprintf(" (%d)", year-c.Year) + } + return s +} diff --git a/internal/caldav/backend.go b/internal/caldav/backend.go index 24d13d6..e0a1cbb 100644 --- a/internal/caldav/backend.go +++ b/internal/caldav/backend.go @@ -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 diff --git a/internal/caldav/backend_test.go b/internal/caldav/backend_test.go index 3a74d39..bbbbb1f 100644 --- a/internal/caldav/backend_test.go +++ b/internal/caldav/backend_test.go @@ -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, "`+defaultBirthdayColor+`FF`) { + t.Fatalf("expected default birthdays calendar-color, got: %s", body) } } diff --git a/internal/caldav/birthdays.go b/internal/caldav/birthdays.go new file mode 100644 index 0000000..f1733de --- /dev/null +++ b/internal/caldav/birthdays.go @@ -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 +// "~~.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 +} diff --git a/internal/db/db.go b/internal/db/db.go index 0eb1607..cf05862 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -96,6 +96,15 @@ CREATE TABLE IF NOT EXISTS addressbook_shares ( UNIQUE (owner, addressbook_name, shared_with) ); +-- Per-user display color for the synthetic "Birthdays" calendar (computed +-- from contacts' BDAY fields, not a real user-created calendar โ€” see +-- internal/web/calendar.go and internal/caldav/backend.go). One row per +-- user; absent means "use the built-in default color". +CREATE TABLE IF NOT EXISTS birthday_calendars ( + owner TEXT PRIMARY KEY REFERENCES users (username) ON DELETE CASCADE, + color TEXT NOT NULL DEFAULT '' +); + -- Web UI login sessions. Sessions are opaque random tokens stored server -- side (not JWTs) so they can be revoked instantly by deleting the row. CREATE TABLE IF NOT EXISTS web_sessions ( diff --git a/internal/db/users.go b/internal/db/users.go index b2d5de3..9e4537f 100644 --- a/internal/db/users.go +++ b/internal/db/users.go @@ -24,6 +24,19 @@ var ErrResourceExists = errors.New("resource already exists") // that doesn't exist. var ErrResourceNotFound = errors.New("resource not found") +// ErrReservedName is returned when trying to create a calendar whose name +// is reserved for a computed/virtual calendar (see reservedCalendarNames). +var ErrReservedName = errors.New("calendar name is reserved") + +// reservedCalendarNames are calendar names that can't be used for a real, +// user-created calendar because they're reserved for a synthetic, +// computed calendar shown alongside real ones (e.g. "birthdays", see +// internal/web/calendar.go and internal/caldav/backend.go). Matched +// case-insensitively. +var reservedCalendarNames = map[string]bool{ + "birthdays": true, +} + // User is an account stored in the database. type User struct { Username string @@ -175,8 +188,11 @@ func (d *DB) CreateCalendar(owner, name string) error { // CreateCalendarWithColor registers a new calendar owned by owner with the // given color (an empty string means no color is set, in which case the // client picks its own default). Returns ErrResourceExists if it already -// exists. +// exists, or ErrReservedName if name is reserved for a computed calendar. func (d *DB) CreateCalendarWithColor(owner, name, color string) error { + if reservedCalendarNames[strings.ToLower(name)] { + return ErrReservedName + } _, err := d.conn.Exec(`INSERT INTO calendars (owner, name, color) VALUES (?, ?, ?)`, owner, name, color) if err != nil { if isUniqueConstraintErr(err) { @@ -254,6 +270,34 @@ func (d *DB) ListCalendars(owner string) ([]Calendar, error) { return cals, rows.Err() } +// GetBirthdayCalendarColor returns owner's display color for the +// synthetic "Birthdays" calendar, or "" if they haven't set one (callers +// should fall back to a built-in default in that case). +func (d *DB) GetBirthdayCalendarColor(owner string) (string, error) { + var color string + err := d.conn.QueryRow(`SELECT color FROM birthday_calendars WHERE owner = ?`, owner).Scan(&color) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("getting birthday calendar color: %w", err) + } + return color, nil +} + +// SetBirthdayCalendarColor sets (or updates) owner's display color for +// the synthetic "Birthdays" calendar. +func (d *DB) SetBirthdayCalendarColor(owner, color string) error { + _, err := d.conn.Exec(` + INSERT INTO birthday_calendars (owner, color) VALUES (?, ?) + ON CONFLICT (owner) DO UPDATE SET color = excluded.color`, + owner, color) + if err != nil { + return fmt.Errorf("setting birthday calendar color: %w", err) + } + return nil +} + // -------- address books -------- // CreateAddressBook registers a new address book owned by owner. Returns diff --git a/internal/web/calendar.go b/internal/web/calendar.go index 615e63d..bfadf15 100644 --- a/internal/web/calendar.go +++ b/internal/web/calendar.go @@ -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", }) diff --git a/internal/web/dashboard.go b/internal/web/dashboard.go index 980fd13..a00bb22 100644 --- a/internal/web/dashboard.go +++ b/internal/web/dashboard.go @@ -49,7 +49,9 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { // #resources list after a create/delete (since the set of cards changes, // unlike a share update which only changes one card's contents). func (s *Server) resourceCards(username string) ([]templates.ResourceCard, error) { - var resources []templates.ResourceCard + resources := []templates.ResourceCard{ + {Kind: "calendar", Name: "Birthdays", Color: s.birthdayCalendarColor(username), Virtual: true}, + } calNames, err := s.dbase.ListCalendars(username) if err != nil { diff --git a/internal/web/resources.go b/internal/web/resources.go index f35f829..8fb5155 100644 --- a/internal/web/resources.go +++ b/internal/web/resources.go @@ -69,6 +69,37 @@ func (s *Server) handleCalendarColor(w http.ResponseWriter, r *http.Request) { _ = templates.ResourceCardView(card).Render(r.Context(), w) } +// handleBirthdayColor updates the current user's display color for the +// synthetic "Birthdays" calendar, mounted at /resources/birthdays/color. +// It re-renders just that card (not the whole list), since the set of +// cards doesn't change. +func (s *Server) handleBirthdayColor(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + username := userFromContext(r.Context()) + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + color := strings.TrimSpace(r.PostForm.Get("color")) + if color != "" && !hexColorRe.MatchString(color) { + http.Error(w, "color must be a hex value like #3b82f6", http.StatusBadRequest) + return + } + if err := s.dbase.SetBirthdayCalendarColor(username, color); err != nil { + s.logger.Error("setting birthday calendar color", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + card := templates.ResourceCard{Kind: "calendar", Name: "Birthdays", Color: s.birthdayCalendarColor(username), Virtual: true} + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = templates.ResourceCardView(card).Render(r.Context(), w) +} + func (s *Server) handleResource(w http.ResponseWriter, r *http.Request, kind string) { username := userFromContext(r.Context()) @@ -109,6 +140,10 @@ func (s *Server) handleResource(w http.ResponseWriter, r *http.Request, kind str http.Error(w, "already exists", http.StatusConflict) return } + if err == db.ErrReservedName { + http.Error(w, "this name is reserved for a computed calendar", http.StatusConflict) + return + } s.logger.Error("creating resource", "kind", kind, "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return diff --git a/internal/web/server.go b/internal/web/server.go index de52186..9eaf37e 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -44,6 +44,7 @@ func (s *Server) Handler(staticFS http.FileSystem) http.Handler { mux.HandleFunc("/shares/addressbook", s.requireLogin(s.handleAddressBookShare)) mux.HandleFunc("/resources/calendar", s.requireLogin(s.handleCalendarResource)) mux.HandleFunc("/resources/calendar/color", s.requireLogin(s.handleCalendarColor)) + mux.HandleFunc("/resources/birthdays/color", s.requireLogin(s.handleBirthdayColor)) mux.HandleFunc("/resources/addressbook", s.requireLogin(s.handleAddressBookResource)) mux.HandleFunc("/files/{path...}", s.requireLogin(s.handleFiles)) mux.HandleFunc("/contacts", s.requireLogin(s.handleContactsHome)) diff --git a/internal/web/templates/dashboard.templ b/internal/web/templates/dashboard.templ index 85e9cbe..bf383a7 100644 --- a/internal/web/templates/dashboard.templ +++ b/internal/web/templates/dashboard.templ @@ -11,10 +11,11 @@ type ShareRow struct { // ResourceCard describes one of the user's own calendars/address books // plus who it's currently shared with. type ResourceCard struct { - Kind string // "calendar" or "addressbook" - Name string - Color string // hex color like "#3b82f6"; only used for calendars - Shares []ShareRow + Kind string // "calendar" or "addressbook" + Name string + Color string // hex color like "#3b82f6"; only used for calendars + Shares []ShareRow + Virtual bool // true for computed calendars (e.g. birthdays): no delete/sharing, just a color picker } // SharedWithMeItem describes a resource another user has shared with the @@ -115,7 +116,7 @@ templ ResourceCardView(r ResourceCard) {

if r.Kind == "calendar" {
{ r.Kind } + if r.Virtual { + (computed from contacts) + }

- + if !r.Virtual { + + } -
    - for _, sh := range r.Shares { -
  • - { sh.SharedWith } - - { sh.Permission } - - -
  • - } - if len(r.Shares) == 0 { -
  • Not shared with anyone yet.
  • - } -
+ if !r.Virtual { +
    + for _, sh := range r.Shares { +
  • + { sh.SharedWith } + + { sh.Permission } + + +
  • + } + if len(r.Shares) == 0 { +
  • Not shared with anyone yet.
  • + } +
- - -
- - -
-
- - -
- - +
+ +
+ + +
+
+ + +
+ +
+ } } +func colorEndpoint(r ResourceCard) string { + if r.Virtual { + return "/web/resources/birthdays/color" + } + return "/web/resources/calendar/color" +} + func shareEndpoint(kind string) string { if kind == "calendar" { return "/web/shares/calendar" diff --git a/internal/web/templates/dashboard_templ.go b/internal/web/templates/dashboard_templ.go index 5a12a0f..537e9d0 100644 --- a/internal/web/templates/dashboard_templ.go +++ b/internal/web/templates/dashboard_templ.go @@ -19,10 +19,11 @@ type ShareRow struct { // ResourceCard describes one of the user's own calendars/address books // plus who it's currently shared with. type ResourceCard struct { - Kind string // "calendar" or "addressbook" - Name string - Color string // hex color like "#3b82f6"; only used for calendars - Shares []ShareRow + Kind string // "calendar" or "addressbook" + Name string + Color string // hex color like "#3b82f6"; only used for calendars + Shares []ShareRow + Virtual bool // true for computed calendars (e.g. birthdays): no delete/sharing, just a color picker } // SharedWithMeItem describes a resource another user has shared with the @@ -92,7 +93,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Owner) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 85, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 86, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -105,7 +106,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 85, Col: 68} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 86, Col: 68} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -118,7 +119,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Kind) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 86, Col: 47} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 87, Col: 47} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -131,7 +132,7 @@ func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedW var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Permission) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 88, Col: 83} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 89, Col: 83} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -233,7 +234,7 @@ func ResourceCardView(r ResourceCard) templ.Component { var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("resource-" + r.Kind + "-" + r.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 113, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 114, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9) if templ_7745c5c3_Err != nil { @@ -244,245 +245,288 @@ func ResourceCardView(r ResourceCard) templ.Component { return templ_7745c5c3_Err } if r.Kind == "calendar" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 133, Col: 12} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(r.Kind) + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 134, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 134, Col: 12} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
    ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - for _, sh := range r.Shares { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
  • ") + if !r.Virtual { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
  • ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\">Delete") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if len(r.Shares) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
  • Not shared with anyone yet.
  • ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var24 string - templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 174, Col: 34} + if !r.Virtual { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
    ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, sh := range r.Shares { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(sh.SharedWith) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 158, Col: 27} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(sh.Permission) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 160, Col: 82} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if len(r.Shares) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
  • Not shared with anyone yet.
  • ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" hx-target=\"") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var25 string - templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 175, Col: 51} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" hx-swap=\"outerHTML\">
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -490,6 +534,13 @@ func ResourceCardView(r ResourceCard) templ.Component { }) } +func colorEndpoint(r ResourceCard) string { + if r.Virtual { + return "/web/resources/birthdays/color" + } + return "/web/resources/calendar/color" +} + func shareEndpoint(kind string) string { if kind == "calendar" { return "/web/shares/calendar"