Files
2026-08-09 18:03:09 +01:00

149 lines
3.6 KiB
Go

// Package vcard implements a minimal RFC 6350 vCard parser/builder — just
// the fields CardDAV needs to round-trip contacts: UID, FN, N, EMAIL, TEL,
// ORG, NOTE. Not a full vCard 4.0 implementation (no PHOTO, no groups, no
// extended params) — enough for real mail/contacts clients to store and
// retrieve a usable contact, with more fields added as client compatibility
// testing surfaces the need.
package vcard
import (
"fmt"
"strings"
)
type Card struct {
UID string
FN string // formatted name
N string // structured name: Family;Given;Middle;Prefix;Suffix
Email []string
Tel []string
Org string
Note string
}
// Parse reads a single vCard (BEGIN:VCARD...END:VCARD) into a Card.
func Parse(data string) (*Card, error) {
lines := unfold(data)
c := &Card{}
inCard := false
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
upper := strings.ToUpper(line)
switch {
case upper == "BEGIN:VCARD":
inCard = true
continue
case upper == "END:VCARD":
inCard = false
continue
}
if !inCard {
continue
}
name, value, found := splitProperty(line)
if !found {
continue
}
switch strings.ToUpper(name) {
case "UID":
c.UID = value
case "FN":
c.FN = value
case "N":
c.N = value
case "EMAIL":
c.Email = append(c.Email, value)
case "TEL":
c.Tel = append(c.Tel, value)
case "ORG":
c.Org = value
case "NOTE":
c.Note = unescape(value)
}
}
if c.UID == "" {
return nil, fmt.Errorf("vcard missing required UID property")
}
return c, nil
}
// Build renders a Card back into vCard 4.0 text, CRLF line endings per spec.
func (c *Card) Build() string {
var b strings.Builder
b.WriteString("BEGIN:VCARD\r\n")
b.WriteString("VERSION:4.0\r\n")
b.WriteString("UID:" + c.UID + "\r\n")
if c.FN != "" {
b.WriteString("FN:" + escape(c.FN) + "\r\n")
}
if c.N != "" {
b.WriteString("N:" + c.N + "\r\n")
}
for _, e := range c.Email {
b.WriteString("EMAIL:" + e + "\r\n")
}
for _, t := range c.Tel {
b.WriteString("TEL:" + t + "\r\n")
}
if c.Org != "" {
b.WriteString("ORG:" + escape(c.Org) + "\r\n")
}
if c.Note != "" {
b.WriteString("NOTE:" + escape(c.Note) + "\r\n")
}
b.WriteString("END:VCARD\r\n")
return b.String()
}
// splitProperty splits "NAME;PARAM=x:value" into (name, value) — parameters
// are discarded in this minimal pass (deferred: TYPE=work/home distinction).
func splitProperty(line string) (name, value string, found bool) {
colonIdx := strings.Index(line, ":")
if colonIdx == -1 {
return "", "", false
}
namePart := line[:colonIdx]
value = line[colonIdx+1:]
if semiIdx := strings.Index(namePart, ";"); semiIdx != -1 {
namePart = namePart[:semiIdx]
}
return namePart, value, true
}
// unfold reverses RFC 6350 §3.2 line folding (a line starting with a single
// space or tab is a continuation of the previous line).
func unfold(data string) []string {
raw := strings.Split(strings.ReplaceAll(data, "\r\n", "\n"), "\n")
var out []string
for _, line := range raw {
if len(line) > 0 && (line[0] == ' ' || line[0] == '\t') && len(out) > 0 {
out[len(out)-1] += line[1:]
} else {
out = append(out, line)
}
}
return out
}
func escape(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, ",", "\\,")
s = strings.ReplaceAll(s, ";", "\\;")
s = strings.ReplaceAll(s, "\n", "\\n")
return s
}
func unescape(s string) string {
s = strings.ReplaceAll(s, "\\n", "\n")
s = strings.ReplaceAll(s, "\\,", ",")
s = strings.ReplaceAll(s, "\\;", ";")
s = strings.ReplaceAll(s, "\\\\", "\\")
return s
}