Files
nim-vcard/src/vcard/vcard3.nim
Jonathan Bernard cfac536d60 Fix vCard 3 PROFILE and AGENT handling
Parse PROFILE as the concrete VC3_Profile type instead of a bare
VC3_Property, and validate PROFILE parameters under the correct
content-type name.

Preserve AGENT values in newVC3_Agent so both parsed and constructed
AGENT properties serialize with their actual URI or inline content.

Expand regression coverage to verify PROFILE is exposed and serialized
as the standard property, parsed AGENT URI values round-trip correctly,
and constructed AGENT values retain their content.

AI-Assisted: yes
AI-Tool: OpenAI Codex / gpt-5.4 xhigh
2026-03-28 13:33:20 -05:00

1532 lines
46 KiB
Nim

# vCard 3.0 implementation
# © 2022 Jonathan Bernard
## This module implements a high-performance vCard parser for vCard version
## 3.0 (defined in RFCs 2425_ and 2426_).
##
## .. _rfc2425: https://tools.ietf.org/html/rfc2425
## .. _rfc2426: https://tools.ietf.org/html/rfc2426
import std/[base64, genasts, macros, options, sequtils, streams, strutils,
tables, times, unicode]
import zero_functional
import ./common
import ./private/[internals, lexer]
# Internal enumerations used to capture the value types and properties defined
# by the spec and constants used.
type
VC3_ValueTypes = enum
vtUri = "uri",
vtText = "text",
vtPText = "ptext",
vtDate = "date",
vtTime = "time",
vtDateTime = "date-time",
vtInteger = "integer",
vtBoolean = "boolean",
vtFloat = "float",
vtBinary = "binary",
vtVCard = "vcard"
vtPhoneNumber = "phone-number"
vtUtcOffset = "utc-offset"
VC3_PropertyName = enum
pnName = "NAME"
pnProfile = "PROFILE"
pnSource = "SOURCE"
pnFn = "FN"
pnN = "N"
pnNickname = "NICKNAME"
pnPhoto = "PHOTO"
pnBday = "BDAY"
pnAdr = "ADR"
pnLabel = "LABEL"
pnTel = "TEL"
pnEmail = "EMAIL"
pnMailer = "MAILER"
pnTz = "TZ"
pnGeo = "GEO"
pnTitle = "TITLE"
pnRole = "ROLE"
pnLogo = "LOGO"
pnAgent = "AGENT"
pnOrg = "ORG"
pnCategories = "CATEGORIES"
pnNote = "NOTE"
pnProdid = "PRODID"
pnRev = "REV"
pnSortString = "SORT-STRING"
pnSound = "SOUND"
pnUid = "UID"
pnUrl = "URL"
pnVersion = "VERSION"
pnClass = "CLASS"
pnKey = "KEY"
const propertyCardMap: Table[VC3_PropertyName, VC_PropCardinality] = [
(pnName, vpcAtMostOne),
(pnProfile, vpcAtMostOne),
(pnSource, vpcAtMostOne),
(pnFn, vpcExactlyOne),
(pnN, vpcExactlyOne),
(pnNickname, vpcAtMostOne),
(pnPhoto, vpcAny),
(pnBday, vpcAtMostOne),
(pnAdr, vpcAny),
(pnLabel, vpcAny),
(pnTel, vpcAny),
(pnEmail, vpcAny),
(pnMailer, vpcAny),
(pnTz, vpcAny),
(pnGeo, vpcAny),
(pnTitle, vpcAny),
(pnRole, vpcAny),
(pnLogo, vpcAny),
(pnAgent, vpcAny),
(pnOrg, vpcAny),
(pnCategories, vpcAtMostOne),
(pnNote, vpcAny),
(pnProdid, vpcAtMostOne),
(pnRev, vpcAtMostOne),
(pnSortString, vpcAtMostOne),
(pnSound, vpcAny),
(pnUid, vpcAtMostOne),
(pnUrl, vpcAny),
(pnVersion, vpcExactlyOne),
(pnClass, vpcAny),
(pnKey, vpcAny)
].toTable
const DATE_FMT = "yyyy-MM-dd"
const DATETIME_FMT = "yyyy-MM-dd'T'HH:mm:sszz"
## Externally Exposed Types
## ========================
##
## The following are the object types for the vCard 3.0 implementation
## interface.
type
VC3_Property* = ref object of RootObj
## Abstract base class for all vCard 3.0 property objects.
propertyId: int
group*: Option[string]
name*: string
VC3_SimpleTextProperty* = ref object of VC3_Property
## Abstract base class for all vCard 3.0 properties that only support the
## text value type (`VALUE=text` or `VALUE=ptext`).
value*: string
isPText*: bool ## true if VALUE=ptext, false by default
language*: Option[string]
xParams*: seq[VC_XParam]
VC3_BinaryProperty* = ref object of VC3_Property
## Abstract base class for all vCard 3.0 properties that support the binary
## or uri value types (`ENCODING=b` or `VALUE=uri`).
valueType*: Option[string] ## \
## binary / uri. Stored separately from ENCODING (captured in the
## isInline field) because the VALUE parameter is not set by default, but
## is allowed to be set.
value*: string ## \
## either a URI or bit sequence, both stored as string
binaryType*: Option[string] ## \
## if using ENCODING=b, there may also be a TYPE parameter specifying the
## MIME type of the binary-encoded object, which is stored here.
isInline*: bool ## \
## true if ENCODING=b, false by default
VC3_Name* = ref object of VC3_Property
## Concrete class representing vCard 3.0 NAME properties.
value*: string
VC3_Profile* = ref object of VC3_Property
## Concrete class representing vCard 3.0 PROFILE properties.
VC3_Source* = ref object of VC3_Property
## Concrete class representing vCard 3.0 SOURCE properties.
valueType*: Option[string] ## If set, this must be set to "uri"
value*: string ## The URI value.
context*: Option[string]
xParams*: seq[VC_XParam]
VC3_Fn* = ref object of VC3_SimpleTextProperty
## Concrete class representing vCard 3.0 FN properties.
VC3_N* = ref object of VC3_Property
## Concrete class representing vCard 3.0 N properties.
family*: seq[string] ## Surname / family name
given*: seq[string] ## First name / given name
additional*: seq[string] ## Additional / middle names
prefixes*: seq[string] ## e.g. Mr., Dr., etc.
suffixes*: seq[string] ## e.g. Esq., II, etc.
language*: Option[string]
isPText*: bool ## true if VALUE=ptext, false by default
xParams*: seq[VC_XParam]
VC3_Nickname* = ref object of VC3_SimpleTextProperty
## Concrete class representing vCard 3.0 NICKNAME properties.
VC3_Photo* = ref object of VC3_BinaryProperty
## Concrete class representing vCard 3.0 PHOTO properties.
VC3_Bday* = ref object of VC3_Property
## Concrete class representing vCard 3.0 BDAY properties.
valueType*: Option[string] # date / date-time
value*: DateTime
VC3_AdrTypes* = enum
## Standard types for ADR values defined in RFC2426
atDom = "DOM"
atIntl = "INTL"
atPostal = "POSTAL"
atParcel = "PARCEL"
atHome = "HOME"
atWork = "WORK"
atPref = "PREF"
VC3_Adr* = ref object of VC3_Property
## Concrete class representing vCard 3.0 ADR properties.
adrType*: seq[string]
poBox*: string
extendedAdr*: string
streetAdr*: string
locality*: string
region*: string
postalCode*: string
country*: string
isPText*: bool ## `true` if `VALUE=ptext`, `false` by default
language*: Option[string]
xParams*: seq[VC_XParam]
VC3_Label* = ref object of VC3_SimpleTextProperty
## Concrete class representing vCard 3.0 LABEL properties.
adrType*: seq[string]
VC3_TelTypes* = enum
## Standard types for TEL values defined in RFC2426
ttHome = "HOME",
ttWork = "WORK",
ttPref = "PREF",
ttVoice = "VOICE",
ttFax = "FAX",
ttMsg = "MSG",
ttCell = "CELL",
ttPager = "PAGER",
ttBbs = "BBS",
ttModem = "MODEM",
ttCar = "CAR",
ttIsdn = "ISDN",
ttVideo = "VIDEO",
ttPcs = "PCS"
VC3_Tel* = ref object of VC3_Property
## Concrete class representing vCard 3.0 TEL properties.
telType*: seq[string]
value*: string
VC3_EmailType* = enum
## Standard types for EMAIL values defined in RFC2426
etInternet = "INTERNET",
etX400 = "X400"
VC3_Email* = ref object of VC3_Property
## Concrete class representing vCard 3.0 EMAIL properties.
emailType*: seq[string]
value*: string
VC3_Mailer* = ref object of VC3_SimpleTextProperty
## Concrete class representing vCard 3.0 MAILER properties.
VC3_TZ* = ref object of VC3_Property
## Concrete class representing vCard 3.0 TZ properties.
value*: string
isText*: bool ## `true` if `VALUE=text`, `false` by default
VC3_Geo* = ref object of VC3_Property
## Concrete class representing vCard 3.0 GEO properties.
lat*, long*: float
VC3_Title* = ref object of VC3_SimpleTextProperty
## Concrete class representing vCard 3.0 TITLE properties.
VC3_Role* = ref object of VC3_SimpleTextProperty
## Concrete class representing vCard 3.0 ROLE properties.
VC3_Logo* = ref object of VC3_BinaryProperty
## Concrete class representing vCard 3.0 LOGO properties.
VC3_Agent* = ref object of VC3_Property
## Concrete class representing vCard 3.0 AGENT properties.
value*: string ## either an escaped vCard object, or a URI
isInline*: bool ## `false` if `VALUE=uri`, `true` by default
VC3_Org* = ref object of VC3_Property
## Concrete class representing vCard 3.0 ORG properties.
value*: seq[string]
isPText*: bool ## `true` if `VALUE=ptext`, `false` by default
language*: Option[string]
xParams*: seq[VC_XParam]
VC3_Categories* = ref object of VC3_Property
## Concrete class representing vCard 3.0 CATEGORIES properties.
value*: seq[string]
isPText*: bool ## `true` if `VALUE=ptext`, `false` by default
language*: Option[string]
xParams*: seq[VC_XParam]
VC3_Note* = ref object of VC3_SimpleTextProperty
## Concrete class representing vCard 3.0 NOTE properties.
VC3_Prodid* = ref object of VC3_SimpleTextProperty
## Concrete class representing vCard 3.0 PRODID properties.
VC3_Rev* = ref object of VC3_Property
## Concrete class representing vCard 3.0 REV properties.
valueType*: Option[string] # date / date-time
value*: DateTime
VC3_SortString* = ref object of VC3_SimpleTextProperty
## Concrete class representing vCard 3.0 SORT-STRING properties.
VC3_Sound* = ref object of VC3_BinaryProperty
## Concrete class representing vCard 3.0 SOUND properties.
VC3_UID* = ref object of VC3_Property
## Concrete class representing vCard 3.0 UID properties.
value*: string
VC3_URL* = ref object of VC3_Property
## Concrete class representing vCard 3.0 URL properties.
value*: string
VC3_Version* = ref object of VC3_Property
## Concrete class representing vCard 3.0 VERSION properties.
value*: string # 3.0
VC3_Class* = ref object of VC3_Property
## Concrete class representing vCard 3.0 CLASS properties.
value*: string
VC3_Key* = ref object of VC3_BinaryProperty
## Concrete class representing vCard 3.0 KEY properties.
keyType*: Option[string] # x509 / pgp
VC3_XType* = ref object of VC3_SimpleTextProperty
VCard3* = ref object of VCard
## Concrete class implementing the vCard 3.0 type.
nextPropertyId: int
content*: seq[VC3_Property]
# Internal Utility/Implementation
# ===============================
template takePropertyId(vc3: VCard3): int =
vc3.nextPropertyId += 1
vc3.nextPropertyId - 1
func namesForProp(prop: VC3_PropertyName):
tuple[enumName, typeName, initFuncName, accessorFuncName: NimNode] =
var name: string = ($prop).replace("-", "")
if name.len > 1: name = name[0] & name[1..^1].toLower
return (
ident("pn" & name),
ident("VC3_" & name),
ident("newVC3_" & name),
ident(name.toLower))
## Initializers
## ============
func newVC3_Name*(value: string, group = none[string]()): VC3_Name =
return VC3_Name(name: "NAME", value: value, group: group)
func newVC3_Profile*(group = none[string]()): VC3_Profile =
return VC3_Profile(name: "PROFILE", group: group)
func newVC3_Source*(
value: string,
inclContext = false,
inclValue = false,
xParams: seq[VC_XParam] = @[],
group = none[string]()): VC3_Source =
return assignFields(
VC3_Source(
name: $pnSource,
valueType: if inclValue: some("uri")
else: none[string](),
context: if inclContext: some("word")
else: none[string]()),
value, group, xParams)
func newVC3_Fn*(
value: string,
language = none[string](),
isPText = false,
xParams: seq[VC_XParam] = @[],
group = none[string]()): VC3_Fn =
return assignFields(
VC3_Fn(name: $pnFn),
value, language, isPText, group, xParams)
func newVC3_N*(
family: seq[string] = @[],
given: seq[string] = @[],
additional: seq[string] = @[],
prefixes: seq[string] = @[],
suffixes: seq[string] = @[],
language = none[string](),
isPText = false,
xParams: seq[VC_XParam] = @[],
group = none[string]()): VC3_N =
return assignFields(
VC3_N(name: $pnN),
family, given, additional, prefixes, suffixes, language, xParams)
func newVC3_Nickname*(
value: string,
language = none[string](),
isPText = false,
xParams: seq[VC_XParam] = @[],
group = none[string]()): VC3_Nickname =
return assignFields(
VC3_Nickname(name: $pnNickname),
value, language, isPText, group, xParams)
func newVC3_Photo*(
value: string,
valueType = some("uri"),
binaryType = none[string](),
isInline = false,
group = none[string]()): VC3_Photo =
return assignFields(
VC3_Photo(name: $pnPhoto),
value, valueType, binaryType, isInline, group)
func newVC3_Bday*(
value: DateTime,
valueType = none[string](),
group = none[string]()): VC3_Bday =
return assignFields(VC3_Bday(name: $pnBday), value, valueType, group)
func newVC3_Adr*(
adrType = @[$atIntl,$atPostal,$atParcel,$atWork],
poBox = "",
extendedAdr = "",
streetAdr = "",
locality = "",
region = "",
postalCode = "",
country = "",
language = none[string](),
isPText = false,
group = none[string](),
xParams: seq[VC_XParam] = @[]): VC3_Adr =
return assignFields(
VC3_Adr(name: $pnAdr),
adrType, poBox, extendedAdr, streetAdr, locality, region, postalCode,
country, isPText, language, group, xParams)
func newVC3_Label*(
value: string,
adrType = @[$atIntl,$atPostal,$atParcel,$atWork],
language = none[string](),
isPText = false,
xParams: seq[VC_XParam] = @[],
group = none[string]()): VC3_Label =
return assignFields(
VC3_Label(name: $pnLabel),
value, adrType, language, isPText, group, xParams)
func newVC3_Tel*(
value: string,
telType = @[$ttVoice],
group = none[string]()): VC3_Tel =
return assignFields(VC3_Tel(name: $pnTel), value, telType, group)
func newVC3_Email*(
value: string,
emailType = @[$etInternet],
group = none[string]()): VC3_Email =
return assignFields(VC3_Email(name: $pnEmail), value, emailType, group)
func newVC3_Mailer*(
value: string,
language = none[string](),
isPText = false,
xParams: seq[VC_XParam] = @[],
group = none[string]()): VC3_Mailer =
return assignFields(
VC3_Mailer(name: $pnMailer),
value, language, isPText, xParams, group)
func newVC3_TZ*(value: string, isText = false, group = none[string]()): VC3_TZ =
return assignFields(VC3_TZ(name: $pnTz), value, isText, group)
func newVC3_Geo*(lat, long: float, group = none[string]()): VC3_Geo =
return assignFields(VC3_Geo(name: $pnGeo), lat, long, group)
func newVC3_Title*(
value: string,
language = none[string](),
isPText = false,
xParams: seq[VC_XParam] = @[],
group = none[string]()): VC3_Title =
return assignFields(
VC3_Title(name: $pnTitle),
value, language, isPText, xParams, group)
func newVC3_Role*(
value: string,
language = none[string](),
isPText = false,
xParams: seq[VC_XParam] = @[],
group = none[string]()): VC3_Role =
return assignFields(
VC3_Role(name: $pnRole),
value, language, isPText, xParams, group)
func newVC3_Logo*(
value: string,
valueType = some("uri"),
binaryType = none[string](),
isInline = false,
group = none[string]()): VC3_Logo =
return assignFields(
VC3_Logo(name: $pnLogo),
value, valueType, binaryType, isInline, group)
func newVC3_Agent*(
value: string,
isInline = true,
group = none[string]()): VC3_Agent =
return VC3_Agent(
name: $pnAgent,
value: value,
isInline: isInline,
group: group)
func newVC3_Org*(
value: seq[string],
isPText = false,
language = none[string](),
xParams: seq[VC_XParam] = @[],
group = none[string]()): VC3_Org =
return assignFields(
VC3_Org(name: $pnOrg),
value, isPText, language, xParams, group)
func newVC3_Categories*(
value: seq[string],
isPText = false,
language = none[string](),
xParams: seq[VC_XParam] = @[],
group = none[string]()): VC3_Categories =
return assignFields(
VC3_Categories(name: $pnCategories),
value, isPText, language, xParams, group)
func newVC3_Note*(
value: string,
language = none[string](),
isPText = false,
xParams: seq[VC_XParam] = @[],
group = none[string]()): VC3_Note =
return assignFields(
VC3_Note(name: $pnNote),
value, language, isPText, xParams, group)
func newVC3_Prodid*(
value: string,
language = none[string](),
isPText = false,
xParams: seq[VC_XParam] = @[],
group = none[string]()): VC3_Prodid =
return assignFields(
VC3_Prodid(name: $pnProdid),
value, language, isPText, xParams, group)
func newVC3_Rev*(
value: DateTime,
valueType = none[string](),
group = none[string]()): VC3_Rev =
return assignFields(VC3_Rev(name: $pnRev), value, valueType, group)
func newVC3_SortString*(
value: string,
language = none[string](),
isPText = false,
xParams: seq[VC_XParam] = @[],
group = none[string]()): VC3_SortString =
return assignFields(
VC3_SortString(name: $pnSortstring),
value, language, isPText, xParams, group)
func newVC3_Sound*(
value: string,
valueType = some("uri"),
binaryType = none[string](),
isInline = false,
group = none[string]()): VC3_Sound =
return assignFields(
VC3_Sound(name: $pnSound),
value, valueType, binaryType, isInline, group)
func newVC3_UID*(value: string, group = none[string]()): VC3_UID =
return VC3_UID(name: $pnUid, value: value, group: group)
func newVC3_URL*(value: string, group = none[string]()): VC3_URL =
return VC3_URL(name: $pnUrl, value: value, group: group)
func newVC3_Version*(group = none[string]()): VC3_Version =
return VC3_Version(name: $pnVersion, value: "3.0", group: group)
func newVC3_Class*(value: string, group = none[string]()): VC3_Class =
return VC3_Class(name: $pnClass, value: value, group: group)
func newVC3_Key*(
value: string,
valueType = none[string](),
keyType = none[string](),
isInline = false,
group = none[string]()): VC3_Key =
if valueType.isSome:
if valueType.get == $vtUri:
raise newException(ValueError,
"KEY content does not support the '" & $vtUri & "' value type")
elif valueType.get == $vtBinary and not isInline:
raise newException(ValueError,
"KEY content with VALUE=binary must also specify ENCODING=b")
elif valueType.get == $vtText and isInline:
raise newException(ValueError,
"KEY content with ENCODING=b cannot use VALUE=text")
return assignFields(
VC3_Key(name: $pnKey, binaryType: keyType),
value, valueType, keyType, isInline, group)
func newVC3_XType*(
name: string,
value: string,
language = none[string](),
isPText = false,
xParams: seq[VC_XParam] = @[],
group = none[string]()): VC3_XType =
if not name.startsWith("X-"):
raise newException(ValueError, "Extended types must begin with 'x-'.")
return assignFields(
VC3_XType(name: name),
value, language, isPText, xParams, group)
# Accessors
# =========
func forGroup*(vc3: VCard3, group: string): seq[VC3_Property] =
## Return all properties defined on the vCard associated with the given
## group.
return vc3.content.filterIt(it.group.isSome and it.group.get == group)
func groups*(vc3: VCard3): seq[string] =
## Return a list of all groups present on the vCard
result = @[]
for c in vc3.content:
if c.group.isSome:
let grp = c.group.get
if not result.contains(grp): result.add(grp)
macro genPropertyAccessors(
properties: static[openarray[(VC3_PropertyName, VC_PropCardinality)]]
): untyped =
result = newStmtList()
for (pn, pCard) in properties:
let (_, typeName, _, funcName) = namesForProp(pn)
case pCard:
of vpcAtMostOne:
let funcDef = genAstOpt({kDirtyTemplate}, funcName, typeName):
func funcName*(vc3: VCard3): Option[typeName] =
result = findFirst[typeName, VC3_Property](vc3.content)
funcDef[6].insert(0, newCommentStmtNode(
"Return the single " & $pn & " property (if present)."))
result.add(funcDef)
of vpcExactlyOne:
let funcDef = genAstOpt({kDirtyTemplate}, funcName, pn, typeName):
func funcName*(vc3: VCard3): typeName =
let props = findAll[typeName, VC3_Property](vc3.content)
if props.len != 1:
raise newException(ValueError,
"VCard should have exactly one $# property, but $# were found" %
[$pn, $props.len])
result = props[0]
funcDef[6].insert(0, newCommentStmtNode(
"Return the " & $pn & " property."))
result.add(funcDef)
of vpcAtLeastOne, vpcAny:
let funcDef = genAstOpt({kDirtyTemplate}, funcName, typeName):
func funcName*(vc3: VCard3): seq[typeName] =
result = findAll[typeName, VC3_Property](vc3.content)
funcDef[6].insert(0, newCommentStmtNode(
"Return all instances of the " & $pn & " property."))
result.add(funcDef)
genPropertyAccessors(propertyCardMap.pairs.toSeq -->
filter(not [pnVersion].contains(it[0])))
func version*(vc3: VCard3): VC3_Version =
## Return the VERSION property.
let found = findFirst[VC3_Version, VC3_Property](vc3.content)
if found.isSome: return found.get
else: return VC3_Version(
propertyId: vc3.content.len + 1,
group: none[string](),
name: "VERSION",
value: "3.0")
func xTypes*(vc3: VCard3): seq[VC3_XType] = findAll[VC3_XType, VC3_Property](vc3.content)
## Return all extended properties (starting with `x-`).
# Setters
# =============================================================================
func set*[T: VC3_Property](vc3: VCard3, content: varargs[T]): void =
## Set the given property on the vCard. This will replace the first existing
## instance of this property or append this as a new property if there is no
## existing instance of this property. This is useful for properties which
## should only appear once within the vCard.
for c in content:
var nc = c
let existingIdx = vc3.content.indexOfIt(it of T)
if existingIdx < 0:
nc.propertyId = vc3.takePropertyId
vc3.content.add(nc)
else:
nc.propertyId = vc3.content[existingIdx].propertyId
vc3.content[existingIdx] = nc
func add*[T: VC3_Property](vc3: VCard3, content: varargs[T]): void =
## Add a new property to the end of the vCard.
for c in content:
var nc = c
nc.propertyId = vc3.takePropertyId
vc3.content.add(nc)
func updateOrAdd*[T: VC3_Property](vc3: VCard3, content: seq[T]): VCard3 =
for c in content:
let existingIdx = vc3.content.indexOfIt(it.propertyId == c.propertyId)
if existingIdx < 0: vc3.content.add(c)
else: c.content[existingIdx] = c
# Output
# =============================================================================
func nameWithGroup(s: VC3_Property): string =
if s.group.isSome: s.group.get & "." & s.name
else: s.name
func serialize(xp: seq[VC_XParam]): string =
return (xp --> map(";" & it.name & "=" & it.value)).join("")
func serialize(s: VC3_Source): string =
result = s.nameWithGroup
if s.valueType.isSome: result &= ";VALUE=" & s.valueType.get
if s.context.isSome: result &= ";CONTEXT=" & s.context.get
result &= serialize(s.xParams)
result &= ":" & s.value
func serializeTextValue(value: string): string =
value.multiReplace([
("\\", "\\\\"),
("\n", "\\n"),
(";", "\\;"),
(",", "\\,")
])
func serializeTextValues(values: seq[string], sep: string): string =
(values --> map(serializeTextValue(it))).join(sep)
func serialize(n: VC3_N): string =
result = n.nameWithGroup
if n.isPText: result &= ";VALUE=ptext"
if n.language.isSome: result &= ";LANGUAGE=" & n.language.get
result &= serialize(n.xParams)
result &= ":" &
serializeTextValues(n.family, ",") & ";" &
serializeTextValues(n.given, ",") & ";" &
serializeTextValues(n.additional, ",") & ";" &
serializeTextValues(n.prefixes, ",") & ";" &
serializeTextValues(n.suffixes, ",")
func serialize(b: VC3_Bday): string =
result = b.nameWithGroup
if b.valueType.isSome and b.valueType.get == "date-time":
result &= ";VALUE=date-time:" & b.value.format(DATETIME_FMT)
else:
result &= ";VALUE=date:" & b.value.format(DATE_FMT)
func serialize(a: VC3_Adr): string =
result = a.nameWithGroup
if a.adrType.len > 0: result &= ";TYPE=" & a.adrType.join(",")
if a.isPText: result &= ";VALUE=ptext"
if a.language.isSome: result &= ";LANGUAGE=" & a.language.get
result &= serialize(a.xParams)
result &= ":" &
serializeTextValue(a.poBox) & ";" &
serializeTextValue(a.extendedAdr) & ";" &
serializeTextValue(a.streetAdr) & ";" &
serializeTextValue(a.locality) & ";" &
serializeTextValue(a.region) & ";" &
serializeTextValue(a.postalCode) & ";" &
serializeTextValue(a.country)
proc serialize(t: VC3_Tel): string =
result = t.nameWithGroup
if t.telType.len > 0: result &= ";TYPE=" & t.telType.join(",")
result &= ":" & t.value
proc serialize(t: VC3_Email): string =
result = t.nameWithGroup
if t.emailType.len > 0: result &= ";TYPE=" & t.emailType.join(",")
result &= ":" & t.value
func serialize(s: VC3_SimpleTextProperty): string =
result = s.nameWithGroup
if s.isPText: result &= ";VALUE=ptext"
if s.language.isSome: result &= ";LANGUAGE=" & s.language.get
result &= serialize(s.xParams)
result &= ":" & serializeTextValue(s.value)
proc serialize(b: VC3_BinaryProperty): string =
result = b.nameWithGroup
if b.valueType.isSome: result &= ";VALUE=" & b.valueType.get
if b.isInline: result &= ";ENCODING=b"
if b.binaryType.isSome: result &= ";TYPE=" & b.binaryType.get
result &= ":"
if b.isInline: result &= base64.encode(b.value)
else: result &= b.value
proc serialize(z: VC3_TZ): string =
result = z.nameWithGroup
if z.isText: result &= ";VALUE=text"
result &= ":" & z.value
proc serialize(g: VC3_Geo): string =
result = g.nameWithGroup & ":" & $g.lat & ";" & $g.long
proc serialize(a: VC3_Agent): string =
result = a.nameWithGroup
if not a.isInline: result &= ";VALUE=uri"
result &= ":" & a.value
proc serialize(o: VC3_Org): string =
result = o.nameWithGroup
if o.isPText: result &= ";VALUE=ptext"
if o.language.isSome: result &= ";LANGUAGE=" & o.language.get
result &= serialize(o.xParams)
result &= ":" & serializeTextValues(o.value, ",")
proc serialize(c: VC3_Categories): string =
result = c.nameWithGroup
if c.isPText: result &= ";VALUE=ptext"
if c.language.isSome: result &= ";LANGUAGE=" & c.language.get
result &= serialize(c.xParams)
result &= ":" & serializeTextValues(c.value, ",")
proc serialize(r: VC3_Rev): string =
result = r.nameWithGroup
if r.valueType.isSome and r.valueType.get == "date-time":
result &= ";VALUE=date-time:" & r.value.format(DATETIME_FMT)
elif r.valueType.isSome and r.valueType.get == "date":
result &= ";VALUE=date:" & r.value.format(DATE_FMT)
else:
result &= r.value.format(DATETIME_FMT)
proc serialize(u: VC3_UID | VC3_URL | VC3_VERSION | VC3_Class): string =
result = u.nameWithGroup & ":" & u.value
proc serialize(c: VC3_Property): string =
if c of VC3_Name: return c.nameWithGroup & ":" & cast[VC3_Name](c).value
elif c of VC3_Profile: return c.nameWithGroup & ":VCARD"
elif c of VC3_Source: return serialize(cast[VC3_Source](c))
elif c of VC3_N: return serialize(cast[VC3_N](c))
elif c of VC3_Bday: return serialize(cast[VC3_Bday](c))
elif c of VC3_Adr: return serialize(cast[VC3_Adr](c))
elif c of VC3_Tel: return serialize(cast[VC3_Tel](c))
elif c of VC3_Email: return serialize(cast[VC3_Email](c))
elif c of VC3_TZ: return serialize(cast[VC3_TZ](c))
elif c of VC3_Geo: return serialize(cast[VC3_Geo](c))
elif c of VC3_Agent: return serialize(cast[VC3_Agent](c))
elif c of VC3_Org: return serialize(cast[VC3_Org](c))
elif c of VC3_Categories: return serialize(cast[VC3_Categories](c))
elif c of VC3_Rev: return serialize(cast[VC3_Rev](c))
elif c of VC3_UID: return serialize(cast[VC3_UID](c))
elif c of VC3_URL: return serialize(cast[VC3_URL](c))
elif c of VC3_Version: return serialize(cast[VC3_Version](c))
elif c of VC3_Class: return serialize(cast[VC3_Class](c))
elif c of VC3_SimpleTextProperty:
return serialize(cast[VC3_SimpleTextProperty](c))
elif c of VC3_BinaryProperty:
return serialize(cast[VC3_BinaryProperty](c))
proc `$`*(vc3: VCard3): string =
## Serialize a vCard into its textual representation.
result = "BEGIN:VCARD" & CRLF
result &= "VERSION:3.0" & CRLF
for c in vc3.content.filterIt(not (it of VC3_Version)):
result &= foldContentLine(serialize(c)) & CRLF
result &= "END:VCARD" & CRLF
# Parsing
# =============================================================================
proc readParamValue(p: var VCardParser): string =
## Read a single parameter value at the current read position or error.
p.setBookmark
if p.peek == '"':
discard p.read
while QSAFE_CHARS.contains(p.peek): discard p.read
if p.read != '"':
p.error("quoted parameter value expected to end with a " &
"double quote (\")")
result = p.readSinceBookmark[1 ..< ^1]
else:
while SAFE_CHARS.contains(p.peek): discard p.read
result = p.readSinceBookmark
p.unsetBookmark
if result.len == 0:
p.error("expected to read a parameter value")
proc readParams(p: var VCardParser): seq[VC_Param] =
## Read all parameters for the current content line at the current read head.
result = @[]
while p.peek == ';':
discard p.read
var param: VCParam = (p.readName, @[])
p.expect("=", true)
param.values.add(p.readParamValue)
while p.peek == ',':
discard p.read
param.values.add(p.readParamValue)
result.add(param)
proc getXParams(params: openarray[VCParam]): seq[VC_XParam] =
## Filter out and return only the non-standard parameters starting with "x-"
let ps = params.toSeq
return ps -->
filter(it.name.startsWith("x-")).
map((name: it.name, value: it.values.join(",")))
proc readTextValue(p: var VCardParser, ignorePrefix: set[char] = {}): string =
## Read a text-value (defined by RFC2426) from the current read position.
## text-value is a more constrained definition of possible value characters
## used in content types like N and ADR.
result = newStringOfCap(32)
let validChars = SAFE_CHARS + {'"', ':', '\\'}
while ignorePrefix.contains(p.peek): discard p.read
while validChars.contains(p.peek):
let c = p.read
if c == '\\':
case p.peek
of '\\', ';', ',': result.add(p.read)
of 'n', 'N':
result.add('\n')
discard p.read
else:
p.error("invalid character escape: '\\$1'" % [$p.read])
else: result.add(c)
proc readTextValueList(
p: var VCardParser,
seps: set[char] = {','},
ifPrefix = none[char]()
): seq[string] =
## Read in a list of multiple text-value (defined by RFC2426) values from the
## current read position. This is used, for example, by the N content type.
if ifPrefix.isSome:
if p.peek != ifPrefix.get: return @[]
discard p.read
result = @[p.readTextValue]
while seps.contains(p.peek): result.add(p.readTextValue(ignorePrefix = seps))
proc decodeTextValue(p: var VCardParser, value: string): string =
result = newStringOfCap(value.len)
var idx = 0
while idx < value.len:
let c = value[idx]
if c != '\\':
result.add(c)
inc idx
continue
if idx + 1 >= value.len:
p.error("invalid character escape: '\\'")
case value[idx + 1]
of '\\', ';', ',':
result.add(value[idx + 1])
of 'n', 'N':
result.add('\n')
else:
p.error("invalid character escape: '\\$1'" % [$value[idx + 1]])
inc idx, 2
proc readBinaryValue(
p: var VCardParser,
isInline: bool,
propName: string
): string =
let value = p.readValue
if not isInline:
return value
try:
return base64.decode(value)
except ValueError:
p.error("invalid base64 value for the " & propName & " content type")
proc parseContentLines*(p: var VCardParser): seq[VC3_Property] =
result = @[]
macro assignCommon(assign: untyped): untyped =
result = assign
result.add(newTree(nnkExprEqExpr, ident("group"), ident("group")))
result.add(newTree(nnkExprEqExpr,
ident("language"),
newCall(ident("getSingleValue"),
ident("params"),
newStrLitNode("LANGUAGE"))))
result.add(newTree(nnkExprEqExpr,
ident("isPText"),
newCall(ident("existsWithValue"),
ident("params"),
newStrLitNode("VALUE"),
newTree(nnkPrefix, ident("$"), ident("vtPText")))))
result.add(newTree(nnkExprEqExpr,
ident("xParams"),
newCall(ident("getXParams"), ident("params"))))
while true:
let group = p.readGroup
let name = p.readName
if name == "END":
p.expect(":VCARD" & CRLF)
break
let params = p.readParams
p.expect(":")
case name
of $pnName:
p.validateNoParameters(params, "NAME")
result.add(newVC3_Name(p.readValue, group))
of $pnProfile:
if p.readValue.toUpper != "VCARD":
p.error("the value of the PROFILE content type must be \"$1\"" %
["vcard"])
p.validateNoParameters(params, "PROFILE")
result.add(newVC3_Profile(group))
of $pnSource:
p.validateRequiredParameters(params,
[("CONTEXT", "word"), ("VALUE", "uri")])
result.add(newVC3_Source(
group = group,
value = p.readValue,
inclContext = params.existsWithValue("CONTEXT", "WORD"),
inclValue = params.existsWithValue("VALUE", $vtUri),
xParams = params.getXParams))
of $pnFn:
result.add(assignCommon(newVC3_Fn(
value = p.decodeTextValue(p.readValue))))
of $pnN:
result.add(assignCommon(newVC3_N(
family = p.readTextValueList,
given = p.readTextValueList(ifPrefix = some(';')),
additional = p.readTextValueList(ifPrefix = some(';')),
prefixes = p.readTextValueList(ifPrefix = some(';')),
suffixes = p.readTextValueList(ifPrefix = some(';')))))
of $pnNickname:
result.add(assignCommon(newVC3_Nickname(
value = p.decodeTextValue(p.readValue))))
of $pnPhoto:
let isInline = params.existsWithValue("ENCODING", "B")
result.add(newVC3_Photo(
group = group,
value = p.readBinaryValue(isInline, "PHOTO"),
valueType = params.getSingleValue("VALUE"),
binaryType = params.getSingleValue("TYPE"),
isInline = isInline))
of $pnBday:
let valueType = params.getSingleValue("VALUE")
let valueStr = p.readValue
var value: DateTime
try:
if valueType.isSome and valueType.get == $vtDate:
value = parseDate(valueStr)
elif valueType.isSome and valueType.get == $vtDateTime:
value = parseDateTime(valueStr)
elif valueType.isSome:
p.error("invalid VALUE for BDAY content. " &
"Expected '" & $vtDate & "' or '" & $vtDateTime & "'")
else:
value = parseDateOrDateTime(valueStr)
except ValueError:
p.error("invalid date or date-time value: $1" % [valueStr])
result.add(newVC3_Bday(
group = group,
valueType = valueType,
value = value))
of $pnAdr:
result.add(assignCommon(newVC3_Adr(
adrType = params.getMultipleValues("TYPE"),
poBox = p.readTextValue,
extendedAdr = p.readTextValue(ignorePrefix = {';'}),
streetAdr = p.readTextValue(ignorePrefix = {';'}),
locality = p.readTextValue(ignorePrefix = {';'}),
region = p.readTextValue(ignorePrefix = {';'}),
postalCode = p.readTextValue(ignorePrefix = {';'}),
country = p.readTextValue(ignorePrefix = {';'}))))
of $pnLabel:
result.add(assignCommon(newVC3_Label(
value = p.decodeTextValue(p.readValue),
adrType = params.getMultipleValues("TYPE"))))
of $pnTel:
result.add(newVC3_Tel(
group = group,
value = p.readValue,
telType = params.getMultipleValues("TYPE")))
of $pnEmail:
result.add(newVC3_Email(
group = group,
value = p.readValue,
emailType = params.getMultipleValues("TYPE")))
of $pnMailer:
result.add(assignCommon(newVC3_Mailer(
value = p.decodeTextValue(p.readValue))))
of $pnTz:
result.add(newVC3_Tz(
value = p.readValue,
isText = params.existsWithValue("VALUE", "TEXT")))
of $pnGeo:
let rawValue = p.readValue
try:
let partsStr = rawValue.split(';')
result.add(newVC3_Geo(
group = group,
lat = parseFloat(partsStr[0]),
long = parseFloat(partsStr[1])
))
except ValueError:
p.error("expected two float values separated by ';' for the GEO " &
"content type but received '" & rawValue & "'")
of $pnTitle:
result.add(assignCommon(newVC3_Title(
value = p.decodeTextValue(p.readValue))))
of $pnRole:
result.add(assignCommon(newVC3_Role(
value = p.decodeTextValue(p.readValue))))
of $pnLogo:
let isInline = params.existsWithValue("ENCODING", "B")
result.add(newVC3_Logo(
group = group,
value = p.readBinaryValue(isInline, "LOGO"),
valueType = params.getSingleValue("VALUE"),
binaryType = params.getSingleValue("TYPE"),
isInline = isInline))
of $pnAgent:
let valueParam = params.getSingleValue("VALUE")
if valueParam.isSome and valueParam.get != $vtUri:
p.error("the VALUE parameter must be set to '" & $vtUri &
"' if present on the AGENT content type, but it was '" &
valueParam.get & "'")
result.add(newVC3_Agent(
group = group,
value = p.readValue,
isInline = valueParam.isNone))
of $pnOrg:
result.add(assignCommon(newVC3_Org(
value = p.readTextValueList(seps = {';'}))))
of $pnCategories:
result.add(assignCommon(newVC3_Categories(
value = p.readTextValueList())))
of $pnNote:
result.add(assignCommon(newVC3_Note(value = p.readTextValue)))
of $pnProdid:
result.add(assignCommon(newVC3_Prodid(
value = p.decodeTextValue(p.readValue))))
of $pnRev:
let valueType = params.getSingleValue("VALUE")
let valueStr = p.readValue
var value: DateTime
try:
if valueType.isSome and valueType.get == $vtDate:
value = parseDate(valueStr)
elif valueType.isSome and valueType.get == $vtDateTime:
value = parseDateTime(valueStr)
elif valueType.isSome:
p.error("invalid VALUE for BDAY content. " &
"Expected '" & $vtDate & "' or '" & $vtDateTime & "'")
else:
value = parseDateOrDateTime(valueStr)
except ValueError:
p.error("invalid date or date-time value: $1" % [valueStr])
result.add(newVC3_Rev(
group = group,
value = value,
valueType = valueType
))
of $pnSortString:
result.add(assignCommon(newVC3_SortString(
value = p.decodeTextValue(p.readValue))))
of $pnSound:
let isInline = params.existsWithValue("ENCODING", "B")
result.add(newVC3_Sound(
group = group,
value = p.readBinaryValue(isInline, "SOUND"),
valueType = params.getSingleValue("VALUE"),
binaryType = params.getSingleValue("TYPE"),
isInline = isInline))
of $pnUid:
result.add(newVC3_UID(group = group, value = p.readValue))
of $pnUrl:
result.add(newVC3_URL(group = group, value = p.readValue))
of $pnVersion:
p.expect("3.0")
p.validateNoParameters(params, "VERSION")
result.add(newVC3_Version(group = group))
of $pnClass:
result.add(newVC3_Class(group = group, value = p.readValue))
of $pnKey:
let valueType = params.getSingleValue("VALUE")
let isInline = params.existsWithValue("ENCODING", "B")
if valueType.isSome:
if valueType.get == $vtUri:
p.error("invalid VALUE for KEY content. " &
"Expected '" & $vtText & "' or '" & $vtBinary & "'")
elif valueType.get == $vtBinary and not isInline:
p.error("KEY content with VALUE=binary must also specify ENCODING=b")
elif valueType.get == $vtText and isInline:
p.error("KEY content with ENCODING=b cannot use VALUE=text")
result.add(newVC3_Key(
group = group,
value = p.readBinaryValue(isInline, "KEY"),
valueType = valueType,
keyType = params.getSingleValue("TYPE"),
isInline = isInline))
else:
if not name.startsWith("X-"):
p.error("unrecognized content type: '$1'" % [name])
result.add(newVC3_XType(
name = name,
value = p.readValue,
language = params.getSingleValue("LANGUAGE"),
isPText = params.existsWithValue("VALUE", "PTEXT"),
group = group,
xParams = params -->
filter(not ["value", "language"].contains(it.name)).
map((name: it.name, value: it.values.join(",")))))
p.expect("\r\n")
#[
Simplified Parsing Diagram
```mermaid
stateDiagram-v2
[*] --> StartVCard
StartVCard --> ContentLine: "BEGIN VCARD" CRLF
ContentLine --> EndVCard: "END VCARD" CRLF
ContentLine --> Name
Name --> Name: 0-9/a-z/-/.
Name --> Param: SEMICOLON
Name --> Value: COLON
Param --> Value: COLON
Value --> ContentLine: CRLF
state Param {
[*] --> ParamName
ParamName --> ParamName: 0-9/a-z/-/.
ParamName --> ParamValue: "="
ParamValue --> ParamValue: ","
ParamValue --> PText
ParamValue --> Quoted
PText --> PText: SAFE-CHAR
PText --> [*]
Quoted --> Quoted: QSAFE-CHAR
Quoted --> [*]
}
```
]#
## Private Function Unit Tests
## ============================================================================
proc runVCard3PrivateTests*() =
proc initParser(input: string): VCardParser =
result = VCardParser(filename: "private unittests")
lexer.open(result, newStringStream(input))
# "readGroup":
block:
var p = initParser("mygroup.BEGIN:VCARD")
let g = p.readGroup
assert g.isSome
assert g.get == "mygroup"
# "readGroup with hyphen":
block:
var p = initParser("item-1.BEGIN:VCARD")
let g = p.readGroup
assert g.isSome
assert g.get == "item-1"
# "readGroup without group":
block:
var p = initParser("BEGIN:VCARD")
assert p.readGroup.isNone
# "expect (case-sensitive)":
block:
var p = initParser("BEGIN:VCARD")
p.expect("BEGIN", true)
try:
p.expect(":vcard", true)
assert "" == "expect should have raised an error"
except CatchableError: discard
# "expect (case-insensitive)":
block:
var p = initParser("BEGIN:VCARD")
p.expect("begin")
try:
p.expect("begin")
assert "" == "expect should have raised an error"
except CatchableError: discard
# "readName":
block:
var p = initParser("TEL;tel;x-Example;x-Are1+Name")
assert p.readName == "TEL"
assert p.read == ';'
assert p.readName == "TEL"
assert p.read == ';'
assert p.readName == "X-EXAMPLE"
assert p.read == ';'
assert p.readName == "X-ARE1"
try:
discard p.readName
assert "" == "readName should have raised an error"
except CatchableError: discard
# "readParamValue":
block:
var p = initParser("TEL;TYPE=WORK;TYPE=Fun&Games%:+15551234567")
assert p.readName == "TEL"
assert p.read == ';'
assert p.readName == "TYPE"
assert p.read == '='
assert p.readParamValue == "WORK"
assert p.read == ';'
assert p.readName == "TYPE"
assert p.read == '='
assert p.readParamValue == "Fun&Games%"
# "readParamValue quoted":
block:
var p = initParser("FN;LANGUAGE=\"en\":John Smith")
assert p.readName == "FN"
assert p.read == ';'
assert p.readName == "LANGUAGE"
assert p.read == '='
assert p.readParamValue == "en"
# "readParamValue quoted with comma":
block:
var p = initParser("LABEL;TYPE=\"HOME,POSTAL\":123 Main St.")
assert p.readName == "LABEL"
assert p.read == ';'
assert p.readName == "TYPE"
assert p.read == '='
assert p.readParamValue == "HOME,POSTAL"
# "readParams":
block:
var p = initParser("TEL;TYPE=WORK;TYPE=Fun&Games%,Extra:+15551234567")
assert p.readName == "TEL"
let params = p.readParams
assert params.len == 2
assert params[0].name == "TYPE"
assert params[0].values.len == 1
assert params[0].values[0] == "WORK"
assert params[1].name == "TYPE"
assert params[1].values.len == 2
assert params[1].values[0] == "Fun&Games%"
assert params[1].values[1] == "Extra"
# "readParams quoted":
block:
var p = initParser("FN;LANGUAGE=\"en\":John Smith")
assert p.readName == "FN"
let params = p.readParams
assert params.len == 1
assert params[0].name == "LANGUAGE"
assert params[0].values == @["en"]
# "readValue":
block:
var p = initParser("TEL;TYPE=WORK:+15551234567\r\nFN:John Smith\r\n")
assert p.skip("TEL")
discard p.readParams
assert p.read == ':'
assert p.readValue == "+15551234567"
p.expect("\r\n")
assert p.readName == "FN"
discard p.readParams
assert p.read == ':'
assert p.readValue == "John Smith"
# "readTextValueList":
block:
var p = initParser("Public;John;Quincey,Adams;Rev.;Esq:limited\r\n")
assert p.readTextValueList == @["Public"]
assert p.readTextValueList(ifPrefix = some(';')) == @["John"]
assert p.readTextValueList(ifPrefix = some(';')) == @["Quincey", "Adams"]
assert p.readTextValueList(ifPrefix = some(';')) == @["Rev."]
assert p.readTextValueList(ifPrefix = some(';')) == @["Esq:limited"]
assert p.readTextValueList(ifPrefix = some(';')) == newSeq[string]()
# "existsWithValue":
block:
var p = initParser(";TYPE=WORK;TYPE=VOICE;TYPE=CELL")
let params = p.readParams
assert params.existsWithValue("TYPE", "WORK")
assert params.existsWithValue("TYPE", "CELL")
assert not params.existsWithValue("TYPE", "ISDN")
# "getSingleValue":
block:
var p = initParser(";TYPE=WORK;TYPE=VOICE;TYPE=CELL")
let params = p.readParams
let val = params.getSingleValue("TYPE")
assert val.isSome
assert val.get == "WORK"
assert params.getSingleValue("VALUE").isNone
# "getMultipleValues":
block:
var p = initParser(";TYPE=WORK;TYPE=VOICE;TYPE=CELL")
let params = p.readParams
assert params.getMultipleValues("TYPE") == @["WORK", "VOICE", "CELL"]
assert params.getMultipleValues("VALUE") == newSeq[string]()
# "validateNoParameters":
block:
var p = initParser(";TYPE=WORK;TYPE=VOICE;TYPE=CELL")
let params = p.readParams
p.validateNoParameters(@[], "TEST")
try:
p.validateNoParameters(params, "TEST")
assert "" == "validateNoParameters should have errored"
except CatchableError: discard
# "validateRequredParameters":
block:
var p = initParser(";CONTEXT=word;VALUE=uri;TYPE=CELL")
let params = p.readParams
p.validateRequiredParameters(params,
[("VALUE", "uri"), ("CONTEXT", "word")])
try:
p.validateRequiredParameters(params, [("TYPE", "VOICE")])
assert "" == "validateRequiredParameters should have errored"
except CatchableError: discard
when isMainModule: runVCard3PrivateTests()