Files

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
}