// 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 }