129 lines
3.6 KiB
Go
129 lines
3.6 KiB
Go
// 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"
|
|
|
|
"git.arnef.de/arnef/nidus/internal/db"
|
|
"git.arnef.de/arnef/nidus/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: "🎂 Name", or
|
|
// "🎂 Name (BirthYear)" if c's birth year is known. year is unused for the
|
|
// title itself (kept for API stability/future use) since the birth year is
|
|
// shown as-is rather than a computed age.
|
|
func Summary(c Contact, year int) string {
|
|
s := "🎂 " + c.Name
|
|
if c.Year > 0 {
|
|
s += fmt.Sprintf(" (%d)", c.Year)
|
|
}
|
|
return s
|
|
}
|