From b543b46ad2ab4e52a761a89c677f0eb09f6fed2b Mon Sep 17 00:00:00 2001 From: arnef Date: Sun, 6 Sep 2026 19:24:18 +0200 Subject: [PATCH] fix(web): prefill birthday in contact form for imported contacts --- internal/web/contacts.go | 25 ++++++++++++++++++++++++- internal/web/server_test.go | 23 +++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/internal/web/contacts.go b/internal/web/contacts.go index ac44062..784e386 100644 --- a/internal/web/contacts.go +++ b/internal/web/contacts.go @@ -14,7 +14,9 @@ import ( "sort" "strconv" "strings" + "time" + "git.arnef.de/arnef/nidus/internal/birthdays" "git.arnef.de/arnef/nidus/internal/store" "git.arnef.de/arnef/nidus/internal/web/templates" vcard "github.com/emersion/go-vcard" @@ -490,6 +492,27 @@ func photoDataURL(card vcard.Card) string { return "" } +// birthdayInputValue returns birthday in the "YYYY-MM-DD" form the contact +// form's renders. vCards from other clients often store +// the date in vCard 3.0's compact "YYYYMMDD" form or year-less "--MMDD" +// form; a browser date input silently ignores values it can't parse, which +// would leave the field looking empty and drop the birthday on the next +// save. A year-less value can't be shown in a year-bearing date input as-is, +// so it's pre-filled with the current year — the field still shows the +// contact's real month/day (never the blank state the bug produced), and the +// user can correct the year if they know it. Unrecognized values pass +// through unchanged (the empty string for contacts without a birthday). +func birthdayInputValue(birthday string) string { + month, day, year, ok := birthdays.ParseBirthday(birthday) + if !ok { + return birthday + } + if year == 0 { + year = time.Now().Year() + } + return fmt.Sprintf("%04d-%02d-%02d", year, int(month), day) +} + func labeledValues(card vcard.Card, key string) []templates.LabeledValue { fields := card[key] out := make([]templates.LabeledValue, 0, len(fields)) @@ -541,7 +564,7 @@ func contactFormFromCard(book, id string, data []byte) (templates.ContactFormDat Surname: surname, Organization: card.PreferredValue(vcard.FieldOrganization), Note: card.PreferredValue(vcard.FieldNote), - Birthday: card.PreferredValue(vcard.FieldBirthday), + Birthday: birthdayInputValue(card.PreferredValue(vcard.FieldBirthday)), PhotoDataURL: photoDataURL(card), Phones: ensureAtLeastOne(labeledValues(card, vcard.FieldTelephone)), Emails: ensureAtLeastOne(labeledValues(card, vcard.FieldEmail)), diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 6b0f228..a4d3ccb 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -1,6 +1,7 @@ package web import ( + "fmt" "io" "log/slog" "net/http" @@ -10,6 +11,7 @@ import ( "path/filepath" "strings" "testing" + "time" "git.arnef.de/arnef/nidus/internal/config" "git.arnef.de/arnef/nidus/internal/db" @@ -316,3 +318,24 @@ func TestAccountChangePassword(t *testing.T) { t.Fatal("expected password to have changed") } } + +func TestBirthdayInputValueNormalizesFormats(t *testing.T) { + year := time.Now().Year() + cases := []struct { + in string + want string + }{ + {"", ""}, + {"19920217", "1992-02-17"}, // vCard 3.0 compact + {"19540805", "1954-08-05"}, // compact, no VALUE param + {"--0219", fmt.Sprintf("%04d-02-19", year)}, // year-less -> current year + {"2026-09-06", "2026-09-06"}, // already dashed (web-created) + {"1604-02-27", "1604-02-27"}, // dashed, ancient year preserved + {"garbage", "garbage"}, // unrecognized passes through + } + for _, c := range cases { + if got := birthdayInputValue(c.in); got != c.want { + t.Errorf("birthdayInputValue(%q) = %q, want %q", c.in, got, c.want) + } + } +}