nim-vcard/src/vcard/vcard3.nim

1357 lines
41 KiB
Nim

# vCard 3.0 and 4.0 Nim implementation
# © 2022 Jonathan Bernard
## The `vcard` module implements a high-performance vCard parser for both
## versions 3.0 (defined by RFCs [2425][rfc2425] and [2426][rfc2426]) and 4.0
## (defined by RFC [6350][rfc6350])
##
## [rfc2425]: https://tools.ietf.org/html/rfc2425
## [rfc2426]: https://tools.ietf.org/html/rfc2426
## [rfc6350]: https://tools.ietf.org/html/rfc6350
import std/[base64, macros, options, sequtils, streams, strutils, times,
unicode]
import zero_functional
import ./private/[common, lexer, util]
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_PropertyNames = enum
cnName = "NAME"
cnProfile = "PROFILE"
cnSource = "SOURCE"
cnFn = "FN"
cnN = "N"
cnNickname = "NICKNAME"
cnPhoto = "PHOTO"
cnBday = "BDAY"
cnAdr = "ADR"
cnLabel = "LABEL"
cnTel = "TEL"
cnEmail = "EMAIL"
cnMailer = "MAILER"
cnTz = "TZ"
cnGeo = "GEO"
cnTitle = "TITLE"
cnRole = "ROLE"
cnLogo = "LOGO"
cnAgent = "AGENT"
cnOrg = "ORG"
cnCategories = "CATEGORIES"
cnNote = "NOTE"
cnProdid = "PRODID"
cnRev = "REV"
cnSortString = "SORT-STRING"
cnSound = "SOUND"
cnUid = "UID"
cnUrl = "URL"
cnVersion = "VERSION"
cnClass = "CLASS"
cnKey = "KEY"
VC3_Property* = ref object of RootObj
propertyId: int
group*: Option[string]
name*: string
VC3_SimpleTextProperty* = ref object of VC3_Property
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
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
value*: string
VC3_Profile* = ref object of VC3_Property
VC3_Source* = ref object of VC3_Property
valueType*: Option[string] # uri
value*: string # URI
context*: Option[string]
xParams*: seq[VC_XParam]
VC3_Fn* = ref object of VC3_SimpleTextProperty
VC3_N* = ref object of VC3_Property
family*: seq[string]
given*: seq[string]
additional*: seq[string]
prefixes*: seq[string]
suffixes*: seq[string]
language*: Option[string]
isPText*: bool # true if VALUE=ptext, false by default
xParams*: seq[VC_XParam]
VC3_Nickname* = ref object of VC3_SimpleTextProperty
VC3_Photo* = ref object of VC3_BinaryProperty
VC3_Bday* = ref object of VC3_Property
valueType*: Option[string] # date / date-time
value*: DateTime
VC3_AdrTypes* = enum
# Standard types defined in RFC2426
atDom = "DOM"
atIntl = "INTL"
atPostal = "POSTAL"
atParcel = "PARCEL"
atHome = "HOME"
atWork = "WORK"
atPref = "PREF"
VC3_Adr* = ref object of VC3_Property
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
adrType*: seq[string]
VC3_TelTypes* = enum
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
telType*: seq[string]
value*: string
VC3_EmailType* = enum
etInternet = "INTERNET",
etX400 = "X400"
VC3_Email* = ref object of VC3_Property
emailType*: seq[string]
value*: string
VC3_Mailer* = ref object of VC3_SimpleTextProperty
VC3_TZ* = ref object of VC3_Property
value*: string
isText*: bool # true if VALUE=text, false by default
VC3_Geo* = ref object of VC3_Property
lat*, long*: float
VC3_Title* = ref object of VC3_SimpleTextProperty
VC3_Role* = ref object of VC3_SimpleTextProperty
VC3_Logo* = ref object of VC3_BinaryProperty
VC3_Agent* = ref object of VC3_Property
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
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
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
VC3_Prodid* = ref object of VC3_SimpleTextProperty
VC3_Rev* = ref object of VC3_Property
valueType*: Option[string] # date / date-time
value*: DateTime
VC3_SortString* = ref object of VC3_SimpleTextProperty
VC3_Sound* = ref object of VC3_BinaryProperty
VC3_UID* = ref object of VC3_Property
value*: string
VC3_URL* = ref object of VC3_Property
value*: string
VC3_Version* = ref object of VC3_Property
value*: string # 3.0
VC3_Class* = ref object of VC3_Property
value*: string
VC3_Key* = ref object of VC3_BinaryProperty
keyType*: Option[string] # x509 / pgp
VC3_XType* = ref object of VC3_SimpleTextProperty
# TODO: implications of this being a ref now instead of a concrete object
VCard3* = ref object of VCard
nextPropertyId: int
content*: seq[VC3_Property]
const DATE_FMT = "yyyy-MM-dd"
const DATETIME_FMT = "yyyy-MM-dd'T'HH:mm:sszz"
# Internal Utility/Implementation
# =============================================================================
template takePropertyId(vc3: VCard3): int =
vc3.nextPropertyId += 1
vc3.nextPropertyId - 1
# Initializers
# =============================================================================
func newVC3_Name*(value: string, group = none[string]()): VC3_Name =
return VC3_Name(name: "NAME", value: value, 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: $cnSource,
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: $cnFn),
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: $cnN),
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: $cnNickname),
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: $cnPhoto),
value, valueType, binaryType, isInline, group)
func newVC3_Bday*(
value: DateTime,
valueType = none[string](),
group = none[string]()): VC3_Bday =
return assignFields(VC3_Bday(name: $cnBday), 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: $cnAdr),
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: $cnLabel),
value, adrType, language, isPText, group, xParams)
func newVC3_Tel*(
value: string,
telType = @[$ttVoice],
group = none[string]()): VC3_Tel =
return assignFields(VC3_Tel(name: $cnTel), value, telType, group)
func newVC3_Email*(
value: string,
emailType = @[$etInternet],
group = none[string]()): VC3_Email =
return assignFields(VC3_Email(name: $cnEmail), 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: $cnMailer),
value, language, isPText, xParams, group)
func newVC3_TZ*(value: string, isText = false, group = none[string]()): VC3_TZ =
return assignFields(VC3_TZ(name: $cnTz), value, isText, group)
func newVC3_Geo*(lat, long: float, group = none[string]()): VC3_Geo =
return assignFields(VC3_Geo(name: $cnGeo), 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: $cnTitle),
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: $cnRole),
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: $cnLogo),
value, valueType, binaryType, isInline, group)
func newVC3_Agent*(
value: string,
isInline = true,
group = none[string]()): VC3_Agent =
return VC3_Agent(name: $cnAgent, 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: $cnOrg),
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: $cnCategories),
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: $cnNote),
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: $cnProdid),
value, language, isPText, xParams, group)
func newVC3_Rev*(
value: DateTime,
valueType = none[string](),
group = none[string]()): VC3_Rev =
return assignFields(VC3_Rev(name: $cnRev), 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: $cnSortstring),
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: $cnSound),
value, valueType, binaryType, isInline, group)
func newVC3_UID*(value: string, group = none[string]()): VC3_UID =
return VC3_UID(name: $cnUid, value: value, group: group)
func newVC3_URL*(value: string, group = none[string]()): VC3_URL =
return VC3_URL(name: $cnUrl, value: value, group: group)
func newVC3_Version*(group = none[string]()): VC3_Version =
return VC3_Version(name: $cnVersion, value: "3.0", group: group)
func newVC3_Class*(value: string, group = none[string]()): VC3_Class =
return VC3_Class(name: $cnClass, value: value, group: group)
func newVC3_Key*(
value: string,
valueType = some("uri"),
keyType = none[string](),
isInline = false,
group = none[string]()): VC3_Key =
return assignFields(
VC3_Key(name: $cnKey, 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*(vc: openarray[VC3_Property], group: string): seq[VC3_Property] =
return vc.filterIt(it.group.isSome and it.group.get == group)
func groups*(vc: openarray[VC3_Property]): seq[string] =
result = @[]
for c in vc:
if c.group.isSome:
let grp = c.group.get
if not result.contains(grp): result.add(grp)
func name*(c: openarray[VC3_Property]): Option[VC3_Name] = findFirst[VC3_Name](c)
func name*(vc3: VCard3): Option[VC3_Name] = vc3.content.name
func profile*(c: openarray[VC3_Property]): Option[VC3_Profile] =
findFirst[VC3_Profile](c)
func profile*(vc3: VCard3): Option[VC3_Profile] = vc3.content.profile
func source*(c: openarray[VC3_Property]): seq[VC3_Source] = findAll[VC3_Source](c)
func source*(vc3: VCard3): seq[VC3_Source] = vc3.content.source
func fn*(c: openarray[VC3_Property]): VC3_Fn = findFirst[VC3_Fn](c).get
func fn*(vc3: VCard3): VC3_Fn = vc3.content.fn
func n*(c: openarray[VC3_Property]): VC3_N = findFirst[VC3_N](c).get
func n*(vc3: VCard3): VC3_N = vc3.content.n
func nickname*(c: openarray[VC3_Property]): Option[VC3_Nickname] = findFirst[VC3_Nickname](c)
func nickname*(vc3: VCard3): Option[VC3_Nickname] = vc3.content.nickname
func photo*(c: openarray[VC3_Property]): seq[VC3_Photo] = findAll[VC3_Photo](c)
func photo*(vc3: VCard3): seq[VC3_Photo] = vc3.content.photo
func bday*(c: openarray[VC3_Property]): Option[VC3_Bday] = findFirst[VC3_Bday](c)
func bday*(vc3: VCard3): Option[VC3_Bday] = vc3.content.bday
func adr*(c: openarray[VC3_Property]): seq[VC3_Adr] = findAll[VC3_Adr](c)
func adr*(vc3: VCard3): seq[VC3_Adr] = vc3.content.adr
func label*(c: openarray[VC3_Property]): seq[VC3_Label] = findAll[VC3_Label](c)
func label*(vc3: VCard3): seq[VC3_Label] = vc3.content.label
func tel*(c: openarray[VC3_Property]): seq[VC3_Tel] = findAll[VC3_Tel](c)
func tel*(vc3: VCard3): seq[VC3_Tel] = vc3.content.tel
func email*(c: openarray[VC3_Property]): seq[VC3_Email] = findAll[VC3_Email](c)
func email*(vc3: VCard3): seq[VC3_Email] = vc3.content.email
func mailer*(c: openarray[VC3_Property]): Option[VC3_Mailer] = findFirst[VC3_Mailer](c)
func mailer*(vc3: VCard3): Option[VC3_Mailer] = vc3.content.mailer
func tz*(c: openarray[VC3_Property]): Option[VC3_Tz] = findFirst[VC3_Tz](c)
func tz*(vc3: VCard3): Option[VC3_Tz] = vc3.content.tz
func geo*(c: openarray[VC3_Property]): Option[VC3_Geo] = findFirst[VC3_Geo](c)
func geo*(vc3: VCard3): Option[VC3_Geo] = vc3.content.geo
func title*(c: openarray[VC3_Property]): seq[VC3_Title] = findAll[VC3_Title](c)
func title*(vc3: VCard3): seq[VC3_Title] = vc3.content.title
func role*(c: openarray[VC3_Property]): seq[VC3_Role] = findAll[VC3_Role](c)
func role*(vc3: VCard3): seq[VC3_Role] = vc3.content.role
func logo*(c: openarray[VC3_Property]): seq[VC3_Logo] = findAll[VC3_Logo](c)
func logo*(vc3: VCard3): seq[VC3_Logo] = vc3.content.logo
func agent*(c: openarray[VC3_Property]): Option[VC3_Agent] = findFirst[VC3_Agent](c)
func agent*(vc3: VCard3): Option[VC3_Agent] = vc3.content.agent
func org*(c: openarray[VC3_Property]): seq[VC3_Org] = findAll[VC3_Org](c)
func org*(vc3: VCard3): seq[VC3_Org] = vc3.content.org
func categories*(c: openarray[VC3_Property]): Option[VC3_Categories] =
findFirst[VC3_Categories](c)
func categories*(vc3: VCard3): Option[VC3_Categories] = vc3.content.categories
func note*(c: openarray[VC3_Property]): Option[VC3_Note] = findFirst[VC3_Note](c)
func note*(vc3: VCard3): Option[VC3_Note] = vc3.content.note
func prodid*(c: openarray[VC3_Property]): Option[VC3_Prodid] = findFirst[VC3_Prodid](c)
func prodid*(vc3: VCard3): Option[VC3_Prodid] = vc3.content.prodid
func rev*(c: openarray[VC3_Property]): Option[VC3_Rev] = findFirst[VC3_Rev](c)
func rev*(vc3: VCard3): Option[VC3_Rev] = vc3.content.rev
func sortstring*(c: openarray[VC3_Property]): Option[VC3_SortString] =
findFirst[VC3_SortString](c)
func sortstring*(vc3: VCard3): Option[VC3_SortString] = vc3.content.sortstring
func sound*(c: openarray[VC3_Property]): seq[VC3_Sound] = findAll[VC3_Sound](c)
func sound*(vc3: VCard3): seq[VC3_Sound] = vc3.content.sound
func uid*(c: openarray[VC3_Property]): Option[VC3_UID] = findFirst[VC3_UID](c)
func uid*(vc3: VCard3): Option[VC3_UID] = vc3.content.uid
func url*(c: openarray[VC3_Property]): Option[VC3_URL] = findFirst[VC3_URL](c)
func url*(vc3: VCard3): Option[VC3_URL] = vc3.content.url
func version*(c: openarray[VC3_Property]): VC3_Version =
let found = findFirst[VC3_Version](c)
if found.isSome: return found.get
else: return VC3_Version(
propertyId: c.len + 1,
group: none[string](),
name: "VERSION",
value: "3.0")
func version*(vc3: VCard3): VC3_Version = vc3.content.version
func class*(c: openarray[VC3_Property]): Option[VC3_Class] = findFirst[VC3_Class](c)
func class*(vc3: VCard3): Option[VC3_Class] = vc3.content.class
func key*(c: openarray[VC3_Property]): seq[VC3_Key] = findAll[VC3_Key](c)
func key*(vc3: VCard3): seq[VC3_Key] = vc3.content.key
func xTypes*(c: openarray[VC3_Property]): seq[VC3_XType] = findAll[VC3_XType](c)
func xTypes*(vc3: VCard3): seq[VC3_XType] = vc3.content.xTypes
# Setters
# =============================================================================
func set*[T: VC3_Property](vc3: VCard3, content: varargs[T]): void =
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 =
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(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 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 &= ":" &
n.family.join(",") & ";" &
n.given.join(",") & ";" &
n.additional.join(",") & ";" &
n.prefixes.join(",") & ";" &
n.suffixes.join(",")
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 &= ":" &
a.poBox & ";" &
a.extendedAdr & ";" &
a.streetAdr & ";" &
a.locality & ";" &
a.region & ";" &
a.postalCode & ";" &
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 &= ":" & 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 &= ":" & o.value.join(",")
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 &= ":" & c.value.join(",")
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-time:" & r.value.format(DATETIME_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 =
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 == '"':
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[0 ..< ^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 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 $cnName:
p.validateNoParameters(params, "NAME")
result.add(newVC3_Name(p.readValue, group))
of $cnProfile:
if p.readValue.toUpper != "VCARD":
p.error("the value of the PROFILE content type must be \"$1\"" %
["vcard"])
p.validateNoParameters(params, "NAME")
result.add(VC3_Property(group: group, name: name))
of $cnSource:
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 $cnFn:
result.add(assignCommon(newVC3_Fn(value = p.readValue)))
of $cnN:
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 $cnNickname:
result.add(assignCommon(newVC3_Nickname(value = p.readValue)))
of $cnPhoto:
result.add(newVC3_Photo(
group = group,
value = p.readValue,
valueType = params.getSingleValue("VALUE"),
binaryType = params.getSingleValue("TYPE"),
isInline = params.existsWithValue("ENCODING", "B")))
of $cnBday:
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 $cnAdr:
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 $cnLabel:
result.add(assignCommon(newVC3_Label(
value = p.readValue,
adrType = params.getMultipleValues("TYPE"))))
of $cnTel:
result.add(newVC3_Tel(
group = group,
value = p.readValue,
telType = params.getMultipleValues("TYPE")))
of $cnEmail:
result.add(newVC3_Email(
group = group,
value = p.readValue,
emailType = params.getMultipleValues("TYPE")))
of $cnMailer:
result.add(assignCommon(newVC3_Mailer(value = p.readValue)))
of $cnTz:
result.add(newVC3_Tz(
value = p.readValue,
isText = params.existsWithValue("VALUE", "TEXT")))
of $cnGeo:
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 $cnTitle:
result.add(assignCommon(newVC3_Title(value = p.readValue)))
of $cnRole:
result.add(assignCommon(newVC3_Role(value = p.readValue)))
of $cnLogo:
result.add(newVC3_Logo(
group = group,
value = p.readValue,
valueType = params.getSingleValue("VALUE"),
binaryType = params.getSingleValue("TYPE"),
isInline = params.existsWithValue("ENCODING", "B")))
of $cnAgent:
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 $cnOrg:
result.add(assignCommon(newVC3_Org(
value = p.readTextValueList(seps = {';'}))))
of $cnCategories:
result.add(assignCommon(newVC3_Categories(
value = p.readTextValueList())))
of $cnNote:
result.add(assignCommon(newVC3_Note(value = p.readTextValue)))
of $cnProdid:
result.add(assignCommon(newVC3_Prodid(value = p.readValue)))
of $cnRev:
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 $cnSortString:
result.add(assignCommon(newVC3_SortString(value = p.readValue)))
of $cnSound:
result.add(newVC3_Sound(
group = group,
value = p.readValue,
valueType = params.getSingleValue("VALUE"),
binaryType = params.getSingleValue("TYPE"),
isInline = params.existsWithValue("ENCODING", "B")))
of $cnUid:
result.add(newVC3_UID(group = group, value = p.readValue))
of $cnUrl:
result.add(newVC3_URL(group = group, value = p.readValue))
of $cnVersion:
p.expect("3.0")
p.validateNoParameters(params, "VERSION")
result.add(newVC3_Version(group = group))
of $cnClass:
result.add(newVC3_Class(group = group, value = p.readValue))
of $cnKey:
result.add(newVC3_Key(
group = group,
value = p.readValue,
valueType = params.getSingleValue("VALUE"),
keyType = params.getSingleValue("TYPE"),
isInline = params.existsWithValue("ENCODING", "B")))
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 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%"
# "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"
# "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()