149 lines
3.6 KiB
Go
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
|
|
}
|