nim-vcard/src/vcard/vcard4.nim
Jonathan Bernard 935f1bae2f WIP documentation
- The documentation is cluttered enough as it is with the large number
  of procedures supporting vCard 3 and 4. Split common out into the
  publicly exposed bits and the private internals. This makes it obvious
  which common functionality a client can expect to have exposed on the
  main vcard module.

- Add documentation (WIP) on the vcard3 module.
2023-05-03 02:16:18 -05:00

1492 lines
47 KiB
Nim

import std/[algorithm, genasts, macros, options, sequtils, sets, streams,
strutils, tables, times, unicode]
import zero_functional
#from std/sequtils import toSeq
import ./common
import ./private/[internals, lexer]
type
VC4_ValueType* = enum
vtText = "text",
vtTextList = "text-list",
vtUri = "uri",
vtDate = "date",
vtTime = "time",
vtDateTime = "date-time",
vtDateAndOrTime = "date-and-or-time",
vtTimestamp = "timestamp",
vtBoolean = "boolean",
vtInteger = "integer",
vtFloat = "float",
vtUtcOffset = "utc-offset",
vtLanguageTag = "language-tag"
# not in the standard, added to cover common combinations
vtDateTimeOrText = "_date-time-or-text_"
## used by BDAY and ANNIVERSARY
vtTextOrUri = "_text-or-uri_"
## used by TEL, UID, and KEY
VC4_PropertyName = enum
pnSource = "SOURCE",
pnKind = "KIND",
pnXml = "XML",
pnFn = "FN",
pnN = "N",
pnNickname = "NICKNAME",
pnPhoto = "PHOTO",
pnBday = "BDAY",
pnAnniversary = "ANNIVERSARY",
pnGender = "GENDER",
pnAdr = "ADR",
pnTel = "TEL",
pnEmail = "EMAIL",
pnImpp = "IMPP",
pnLang = "LANG",
pnTz = "TZ",
pnGeo = "GEO",
pnTitle = "TITLE",
pnRole = "ROLE",
pnLogo = "LOGO",
pnOrg = "ORG",
pnMember = "MEMBER",
pnRelated = "RELATED",
pnCategories = "CATEGORIES",
pnNote = "NOTE",
pnProdId = "PRODID",
pnRev = "REV",
pnSound = "SOUND",
pnUid = "UID",
pnClientPidMap = "CLIENTPIDMAP",
pnUrl = "URL",
pnVersion = "VERSION",
pnKey = "KEY",
pnFbUrl = "FBURL",
pnCaladrUri = "CALADRURI",
pnCalUri = "CALURI",
## Non-standard, added to be a catch-all for non-standard names
pnUnknown = "UNKNOWN"
const propertyCardMap: Table[VC4_PropertyName, VC_PropCardinality] = [
(pnSource, vpcAny),
(pnKind, vpcAtMostOne),
(pnXml, vpcAny),
(pnFn, vpcAtLeastOne),
(pnN, vpcAtMostOne),
(pnNickname, vpcAny),
(pnPhoto, vpcAny),
(pnBday, vpcAtMostOne),
(pnAnniversary, vpcAtMostOne),
(pnGender, vpcAtMostOne),
(pnAdr, vpcAny),
(pnTel, vpcAny),
(pnEmail, vpcAny),
(pnImpp, vpcAny),
(pnLang, vpcAny),
(pnTz, vpcAny),
(pnGeo, vpcAny),
(pnTitle, vpcAny),
(pnRole, vpcAny),
(pnLogo, vpcAny),
(pnOrg, vpcAny),
(pnMember, vpcAny),
(pnRelated, vpcAny),
(pnCategories, vpcAny),
(pnNote, vpcAny),
(pnProdId, vpcAtMostOne),
(pnRev, vpcAtMostOne),
(pnSound, vpcAny),
(pnUid, vpcAtMostOne),
(pnClientPidMap, vpcAny),
(pnUrl, vpcAny),
(pnVersion, vpcExactlyOne),
(pnKey, vpcAny),
(pnFbUrl, vpcAny),
(pnCaladrUri, vpcAny),
(pnCalUri, vpcAny)
].toTable()
const fixedValueTypeProperties = [
(pnSource, vtUri),
(pnKind, vtText),
(pnXml, vtText),
(pnFn, vtText),
(pnNickname, vtTextList),
(pnPhoto, vtUri),
(pnBday, vtDateTimeOrText),
(pnAnniversary, vtDateTimeOrText),
(pnTel, vtTextOrUri),
(pnEmail, vtText),
(pnImpp, vtUri),
(pnLang, vtText), # technically "language-tag" but the same in function
(pnTz, vtText), # technically "uri" and "utc-offset" are possible value types
# for this as well, but these differences are basically
# ignored by this implementation. The "uri" value type was
# anticipating a standard that never materialized. The
# "utc-offset" value type is not recommended due to the
# realities of Daylight savings time. The actual storage type
# for both of those types in text (UTC offset could arguably
# by an integer, but the format is significant), so users of
# this library will still have access to the data and can
# query the TYPE parameter to decide how to treat it.
(pnGeo, vtUri),
(pnTitle, vtText),
(pnRole, vtText),
(pnLogo, vtUri),
(pnOrg, vtText),
(pnMember, vtUri),
(pnRelated, vtTextOrUri),
(pnCategories, vtTextList),
(pnNote, vtText),
(pnProdId, vtText),
(pnSound, vtUri),
(pnUid, vtTextOrUri),
(pnUrl, vtUri),
(pnVersion, vtText),
(pnKey, vtTextOrUri),
(pnFbUrl, vtUri),
(pnCaladrUri, vtUri),
(pnCalUri, vtUri)
]
const supportedParams: Table[string, HashSet[VC4_PropertyName]] = [
("LANGUAGE", [pnFn, pnN, pnNickname, pnBday, pnAdr, pnTitle, pnRole,
pnLogo, pnOrg, pnRelated, pnNote, pnSound].toHashSet),
("PREF", (propertyCardMap.pairs.toSeq -->
filter(it[1] == vpcAtLeastOne or it[1] == vpcAny).
map(it[0])).
toHashSet),
# ("ALTID", all properties),
("PID", (propertyCardMap.pairs.toSeq -->
filter((it[1] == vpcAtLeastOne or it[1] == vpcAny) and
it[0] != pnClientPidMap).
map(it[0])).toHashSet),
("TYPE", @[ pnFn, pnNickname, pnPhoto, pnAdr, pnTel, pnEmail, pnImpp, pnLang,
pnTz, pnGeo, pnTitle, pnRole, pnLogo, pnOrg, pnRelated, pnCategories,
pnNote, pnSound, pnUrl, pnKey, pnFburl, pnCaladrUri, pnCalUri ].toHashSet),
].toTable
const TIMESTAMP_FORMATS = [
"yyyyMMdd'T'hhmmssZZZ",
"yyyyMMdd'T'hhmmssZZ",
"yyyyMMdd'T'hhmmssZ",
"yyyyMMdd'T'hhmmss"
]
const TEXT_CHARS = WSP + NON_ASCII + { '\x21'..'\x2B', '\x2D'..'\x7E' }
const COMPONENT_CHARS = WSP + NON_ASCII +
{ '\x21'..'\x2B', '\x2D'..'\x3A', '\x3C'..'\x7E' }
macro genPropTypes(
props: static[openarray[(VC4_PropertyName, VC4_ValueType)]]
): untyped =
result = newNimNode(nnkTypeSection)
for (pn, pt) in props:
var name: string = $pn
if name.len > 1: name = name[0] & name[1..^1].toLower
let typeName = ident("VC4_" & name)
let parentType =
case pt
of vtDateTimeOrText: ident("VC4_DateTimeOrTextProperty")
of vtText: ident("VC4_TextProperty")
of vtTextList: ident("VC4_TextListProperty")
of vtTimestamp: ident("VC4_DateTimeProperty")
of vtTextOrUri: ident("VC4_TextOrUriProperty")
of vtUri: ident("VC4_UriProperty")
else:
raise newException(ValueError,
"types for " & $pn & " properties must be hand-written")
result.add(
nnkTypeDef.newTree(
nnkPostfix.newTree(ident("*"), typeName),
newEmptyNode(),
nnkRefTy.newTree(
nnkObjectTy.newTree(
newEmptyNode(),
nnkOfInherit.newTree(parentType),
newEmptyNode()))))
# echo result.treeRepr
# General VCard 4 data types
type
PidValue* = object
sourceId*: int
propertyId*: int
VC4_Property* = ref object of RootObj
propertyId: int
group*: Option[string]
params*: seq[VC_Param]
VC4_DateTimeOrTextProperty* = ref object of VC4_Property
valueType: VC4_ValueType # should only be vtDateAndOrTime or vtText
value*: string
year*: Option[int]
month*: Option[int]
day*: Option[int]
hour*: Option[int]
minute*: Option[int]
second*: Option[int]
timezone*: Option[string]
VC4_TextListProperty* = ref object of VC4_Property
value*: seq[string]
VC4_TextProperty* = ref object of VC4_Property
value*: string
VC4_TextOrUriProperty* = ref object of VC4_Property
isUrl: bool
value*: string
VC4_UriProperty* = ref object of VC4_Property
mediaType*: Option[string]
value*: string
VC4_DateTimeProperty* = ref object of VC4_Property
value*: DateTime
VC4_Unknown* = ref object of VC4_Property
name*: string
value*: string
VCard4* = ref object of VCard
nextPropertyId: int
content*: seq[VC4_Property]
pidParam*: seq[PidValue]
# Hand-written implementations for more complicated property types
type
VC4_Type* = enum
## Enum defining the well-known values for the TYPE parameter in general.
## Because the specification actually allows any value, this is provided as
## a convenience for accessing the standard values.
tWork = "work",
tHome = "home"
VC4_TelType* = enum
## Enum defining the well-known values for the TYPE parameter used by the
## TEL property. Because the specification actually allows any value, this
## is provided as a convenience for accessing the standard values.
ttWork = "work",
ttHome = "home",
ttText = "text",
ttVoice = "voice",
ttFax = "fax",
ttCell = "cell",
ttVideo = "video",
ttPager = "pager",
ttTextPhone = "textphone"
VC4_RelatedType* = enum
## Enum defining the well-known values for the TYPE parameter used by the
## RELATED property. Because the specification actually allows any value,
## this is provided as a convenience for accessing the standard values.
trContact = "contact",
trAcquaintance = "acquaintance",
trFriend = "friend",
trMet = "met",
trCoWorker = "co-worker",
trColleague = "colleague",
trCoResident = "co-resident",
trNeighbor = "neighbor",
trChild = "child",
trParent = "parent",
trSibling = "sibling",
trSpouse = "spouse",
trKin = "kin",
trMuse = "muse",
trCrush = "crush",
trDate = "date",
trSweetheart = "sweetheart",
trMe = "me",
trAgent = "agent",
trEmergency = "emergency"
VC4_N* = ref object of VC4_Property
family*: seq[string]
given*: seq[string]
additional*: seq[string]
prefixes*: seq[string]
suffixes*: seq[string]
VC4_Sex* = enum
Male = "M"
Female = "F"
Other = "O"
NoneNa = "N"
Unknown = "U"
VC4_Gender* = ref object of VC4_Property
sex*: Option[VC4_Sex]
genderIdentity*: Option[string]
VC4_Adr* = ref object of VC4_Property
poBox*: string
ext*: string
street*: string
locality*: string
region*: string
postalCode*: string
country*: string
VC4_ClientPidMap* = ref object of VC4_Property
id*: int
uri*: string
VC4_Rev* = ref object of VC4_Property
value*: DateTime
# Generate all simple property types
genPropTypes(fixedValueTypeProperties)
# Internal Utility/Implementation
# =============================================================================
template takePropertyId(vc4: VCard4): int =
vc4.nextPropertyId += 1
vc4.nextPropertyId - 1
func flattenParameters(
params: seq[VC_Param],
addtlParams: varargs[VC_Param] = @[]
): seq[VC_Param] =
let paramTable = newTable[string, seq[string]]()
let allParams = params & toSeq(addtlParams)
for p in (allParams --> filter(it.values.len > 0)):
let pname = p.name.toUpper
if paramTable.contains(pname):
for v in p.values:
if not paramTable[pname].contains(v):
paramTable[pname].add(v)
else:
let values = p.values
paramTable[pname] = values
result = @[]
for k, v in paramTable.pairs: result.add((k, v))
proc parseDateAndOrTime[T](
prop: var T,
value: string
): void =
prop.value = value
var p = VCardParser(filename: value)
try:
p.open(newStringStream(value))
p.setBookmark
prop.year = none[int]()
prop.month = none[int]()
prop.day = none[int]()
prop.hour = none[int]()
prop.minute = none[int]()
prop.second = none[int]()
prop.timezone = none[string]()
if p.peek != 'T':
# Attempt to parse the year
if p.peek == '-': p.expect("--")
else: prop.year = some(parseInt(p.readLen(4)))
# Attempt to parse the month
if DIGIT.contains(p.peek) or p.peek == '-':
if p.peek == '-': p.expect("-")
else: prop.month = some(parseInt(p.readLen(2)))
# Attempt to parse the month
if DIGIT.contains(p.peek):
prop.day = some(parseInt(p.readLen(2)))
if p.peek == 'T':
p.expect("T")
# Attempt to parse the hour
if p.peek == '-': p.expect("-")
else: prop.hour = some(parseInt(p.readLen(2)))
# Attempt to parse the minute
if DIGIT.contains(p.peek) or p.peek == '-':
if p.peek == '-': p.expect("-")
else: prop.minute = some(parseInt(p.readLen(2)))
# Attempt to parse the second
if DIGIT.contains(p.peek):
prop.second = some(parseInt(p.readLen(2)))
# Attempt to parse the timezone
if {'-', '+', 'Z'}.contains(p.peek):
try:
p.setBookmark
discard p.read
var i = 0
while i < 4 and DIGITS.contains(p.peek):
discard p.read
i += 1
prop.timezone = some(p.readSinceBookmark)
finally: p.unsetBookmark
except ValueError, IOError, OSError:
p.error("unable to parse date-and-or-time value: " & p.readSinceBookmark)
finally: p.unsetBookmark
proc parseTimestamp(value: string): DateTime =
for fmt in TIMESTAMP_FORMATS:
try: return value.parse(fmt)
except: discard
raise newException(VCardParsingError, "unable to parse timestamp value: " & value)
func parsePidValues(param: VC_Param): seq[PidValue]
{.raises:[VCardParsingError].} =
result = @[]
for v in param.values:
try:
let pieces = v.split(".")
if pieces.len != 2: raise newException(ValueError, "")
result.add(PidValue(
propertyId: parseInt(pieces[0]),
sourceId: parseInt(pieces[1])))
except ValueError:
raise newException(VCardParsingError, "PID value expected to be two " &
"integers separated by '.' (2.1 for example)")
template validateType(p: VCardParser, params: seq[VC_Param], t: VC4_ValueType) =
p.validateRequiredParameters(params, [("VALUE", $t)])
func cmp[T: VC4_Property](x, y: T): int =
return cmp(x.pref, y.pref)
# Initializers
# =============================================================================
func addConditionalParams(prop: VC4_PropertyName, funcDef: NimNode) =
let formalParams = funcDef[3]
let paramsInit =
case funcDef[6][0].kind
# Add parameter mapping to the returned object construction.
# Specifically this is:
# FuncDef
# [6] -> Funtion body StatementList
# [0] -> ReturnStmt
# [0] -> ObjConstr
# [1] -> `params` value
# [1] -> flattenParams call
of nnkReturnStmt: funcDef[6][0][0][1][1]
# Add parameter mapping to the returned object construction.
# Specifically this is:
# FuncDef
# [6] -> Funtion body StatementList
# [0] -> Asgn
# [1] -> ObjConstr
# [1] -> params ExprColonExpr
# [1] -> flattenParams Call
of nnkAsgn: funcDef[6][0][1][1][1]
else:
raise newException(ValueError, "cannot generate conditional params " &
"initialization code for this function shape:\n\r " & funcDef.treeRepr)
if supportedParams["LANGUAGE"].contains(prop):
# Add "language" as a function parameter
formalParams.add(newIdentDefs(
ident("language"),
newEmptyNode(),
quote do: none[string]()))
paramsInit.add(quote do:
("LANGUAGE", if language.isSome: @[language.get] else: @[]))
if supportedParams["PREF"].contains(prop):
# Add "pref" and "pids" as function parameters
formalParams.add(newIdentDefs(
ident("pref"),
newEmptyNode(),
quote do: none[int]()))
paramsInit.add(quote do:
("PREF", if pref.isSome: @[$pref.get] else: @[]))
if supportedParams["PID"].contains(prop):
# Add "pids" as a function parameter
formalParams.add(newIdentDefs(
ident("pids"),
quote do: seq[PidValue],
quote do: @[]))
# Add PID parameter mapping to the object construction. See the note on the
# LANGUAGE for details on how this hooks into the AST
paramsInit.add(quote do: ("PID", pids --> map($it)))
if supportedParams["TYPE"].contains(prop):
# Add "type" as a function parameter
formalParams.add(newIdentDefs(
ident("types"),
quote do: seq[string],
quote do: @[]))
# Add TYPE parameter mapping to the object construction. See the note on
# the LANGUAGE parameter for details on how this hooks into the AST
paramsInit.add(quote do: ("TYPE", types))
func namesForProp(prop: VC4_PropertyName):
tuple[enumName, typeName, initFuncName, accessorName: NimNode] =
var name: string = $prop
if name.len > 1: name = name[0] & name[1..^1].toLower
return (
ident("pn" & name),
ident("VC4_" & name),
ident("newVC4_" & name),
ident(name.toLower))
macro genDateTimeOrTextPropInitializers(
properties: static[openarray[VC4_PropertyName]]
): untyped =
# TODO: the below does not provide for the case where you want to initialize
# a property with a date-and-or-time value that is not a specific DateTime
# instant (for example, a truncated date like "BDAY:--1224", a birthday on
# Dec. 24th without specifying the year.
result = newStmtList()
for prop in properties:
let (enumName, typeName, initFuncName, _) = namesForProp(prop)
let datetimeFuncDef = genAstOpt({kDirtyTemplate}, enumName, initFuncName, typeName):
func initFuncName*(
value: DateTime,
altId: Option[string] = none[string](),
group: Option[string] = none[string](),
params: seq[VC_Param] = @[]): typeName =
return typeName(
params: flattenParameters(params,
("ALTID", if altId.isSome: @[altId.get] else: @[])),
group: group,
value: value.format(TIMESTAMP_FORMATS[0]),
year: some(value.year),
month: some(ord(value.month)),
day: some(ord(value.monthday)),
hour: some(ord(value.hour)),
minute: some(ord(value.minute)),
second: some(ord(value.second)),
timezone: some(value.format("ZZZ")),
valueType: vtDateAndOrTime)
let textFuncDef = genAstOpt({kDirtyTemplate}, enumName, initFuncName, typeName):
proc initFuncName*(
value: string,
valueType: Option[string] = some($vtDateAndOrTime),
altId: Option[string] = none[string](),
group: Option[string] = none[string](),
params: seq[VC_Param] = @[]): typeName =
result = typeName(
params: flattenParameters(params,
("ALTID", if altId.isSome: @[altId.get] else: @[])),
group: group,
value: value,
valueType: vtText)
if valueType.isNone or valueType.get == $vtDateAndOrTime:
result.parseDateAndOrTime(value)
addConditionalParams(prop, datetimeFuncDef)
addConditionalParams(prop, textFuncDef)
result.add(textFuncDef)
result.add(datetimeFuncDef)
macro genTextPropInitializers(
properties: static[openarray[VC4_PropertyName]]
): untyped =
result = newStmtList()
for prop in properties:
let (enumName, typeName, initFuncName, _) = namesForProp(prop)
let funcDef = genAstOpt({kDirtyTemplate}, enumName, initFuncName, typeName):
func initFuncName*(
value: string,
altId: Option[string] = none[string](),
group: Option[string] = none[string](),
params: seq[VC_Param] = @[]): typeName =
return typeName(
params: flattenParameters(params,
("ALTID", if altId.isSome: @[altId.get] else: @[])),
group: group,
value: value)
addConditionalParams(prop, funcDef)
result.add(funcDef)
macro genTextListPropInitializers(
properties: static[openarray[VC4_PropertyName]]
): untyped =
result = newStmtList()
for prop in properties:
let (enumName, typeName, initFuncName, _) = namesForProp(prop)
let funcDef = genAstOpt({kDirtyTemplate}, enumName, initFuncName, typeName):
func initFuncName*(
value: seq[string],
altId: Option[string] = none[string](),
group: Option[string] = none[string](),
params: seq[VC_Param] = @[]): typeName =
return typeName(
params: flattenParameters(params,
("ALTID", if altId.isSome: @[altId.get] else: @[])),
group: group,
value: value)
addConditionalParams(prop, funcDef)
result.add(funcDef)
macro genTextOrUriPropInitializers(
properties: static[openarray[VC4_PropertyName]]
): untyped =
result = newStmtList()
for prop in properties:
let (enumName, typeName, initFuncName, _) = namesForProp(prop)
let funcDef = genAstOpt({kDirtyTemplate}, enumName, initFuncName, typeName):
func initFuncName*(
value: string,
isUrl = false,
altId: Option[string] = none[string](),
group: Option[string] = none[string](),
params: seq[VC_Param] = @[]): typeName =
return typeName(
params: flattenParameters(params,
("ALTID", if altId.isSome: @[altId.get] else: @[])),
isUrl: isUrl,
group: group)
addConditionalParams(prop, funcDef)
result.add(funcDef)
macro genUriPropInitializers(
properties: static[openarray[VC4_PropertyName]]
): untyped =
result = newStmtList()
for prop in properties:
let (enumName, typeName, initFuncName, _) = namesForProp(prop)
let funcDef = genAstOpt({kDirtyTemplate}, enumName, initFuncName, typeName):
func initFuncName*(
value: string,
altId: Option[string] = none[string](),
mediaType: Option[string] = none[string](),
group: Option[string] = none[string](),
params: seq[VC_Param] = @[]): typeName =
return typeName(
params: flattenParameters(params,
("ALTID", if altId.isSome: @[altId.get] else: @[])),
group: group,
mediaType: mediaType,
value: value)
addConditionalParams(prop, funcDef)
result.add(funcDef)
genDateTimeOrTextPropInitializers(fixedValueTypeProperties -->
filter(it[1] == vtDateTimeOrText).map(it[0]))
genTextPropInitializers(fixedValueTypeProperties -->
filter(it[1] == vtText).map(it[0]))
genTextListPropInitializers(fixedValueTypeProperties -->
filter(it[1] == vtTextList).map(it[0]))
genTextOrUriPropInitializers(fixedValueTypeProperties -->
filter(it[1] == vtTextOrUri).map(it[0]))
genUriPropInitializers(fixedValueTypeProperties -->
filter(it[1] == vtUri).map(it[0]))
func newVC4_N*(
family: seq[string] = @[],
given: seq[string] = @[],
additional: seq[string] = @[],
prefixes: seq[string] = @[],
suffixes: seq[string] = @[],
altId: Option[string] = none[string](),
group: Option[string] = none[string](),
params: seq[VC_Param] = @[]): VC4_N =
return assignFields(
VC4_N(params: flattenParameters(params,
("ALTID", if altId.isSome: @[altId.get] else: @[]))),
group, family, given, additional, prefixes, suffixes)
func newVC4_Gender*(
sex: Option[VC4_Sex] = none[VC4_Sex](),
genderIdentity: Option[string] = none[string](),
altId: Option[string] = none[string](),
group: Option[string] = none[string](),
params: seq[VC_Param] = @[]): VC4_Gender =
return assignFields(
VC4_Gender(params: flattenParameters(params,
("ALTID", if altId.isSome: @[altId.get] else: @[]))),
sex, genderIdentity, group)
func newVC4_Adr*(
poBox = "",
ext = "",
street = "",
locality = "",
region = "",
postalCode = "",
country = "",
altId: Option[string] = none[string](),
geo: Option[string] = none[string](),
group: Option[string] = none[string](),
label: Option[string] = none[string](),
language: Option[string] = none[string](),
params: seq[VC_Param] = @[],
pids: seq[PidValue] = @[],
pref: Option[int] = none[int](),
types: seq[string] = @[],
tz: Option[string] = none[string]()): VC4_Adr =
if pref.isSome and (pref.get < 1 or pref.get > 100):
raise newException(ValueError, "PREF must be an integer between 1 and 100")
return assignFields(
VC4_Adr(params: flattenParameters(params,
("ALTID", if altId.isSome: @[altId.get] else: @[]),
("GEO", if geo.isSome: @[geo.get] else: @[]),
("LABEL", if label.isSome: @[label.get] else: @[]),
("LANGUAGE", if language.isSome: @[language.get] else: @[]),
("PID", pids --> map($it)),
("PREF", if pref.isSome: @[$pref.get] else: @[]),
("TYPE", types),
("TZ", if tz.isSome: @[tz.get] else: @[]))),
poBox, ext, street, locality, region, postalCode, country, group)
func newVC4_ClientPidMap*(
id: int,
uri: string,
group: Option[string] = none[string](),
params: seq[VC_Param] = @[]): VC4_ClientPidMap =
result = assignFields(
VC4_ClientPidMap(params: flattenParameters(params)),
id, uri, group)
func newVC4_Rev*(
value: DateTime,
group: Option[string] = none[string](),
params: seq[VC_Param]= @[]): VC4_Rev =
return assignFields(
VC4_Rev(params: flattenParameters(params)),
value, group)
# Accessors
# =============================================================================
macro genPropAccessors(
properties: static[openarray[(VC4_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, pn, typeName):
func funcName*(vc4: VCard4): Option[typeName] =
let alts = allAlternatives[typeName](vc4)
if alts.len > 1:
raise newException(ValueError,
("VCard should have at most one $# property, but $# " &
"distinct properties were found") % [$pn, $alts.len])
if alts.len == 0: result = none[typeName]()
else: result = some(alts[toSeq(alts.keys)[0]][0])
result.add(funcDef)
of vpcExactlyOne:
let funcDef = genAstOpt({kDirtyTemplate}, funcName, pn, typeName):
func funcName*(vc4: VCard4): typeName =
let alts = allAlternatives[typeName](vc4)
if alts.len != 1:
raise newException(ValueError,
"VCard should have exactly one $# property, but $# were found" %
[$pn, $alts.len])
result = alts[toSeq(alts.keys)[0]][0]
result.add(funcDef)
of vpcAtLeastOne, vpcAny:
let funcDef = genAstOpt({kDirtyTemplate}, funcName, typeName):
func funcName*(vc4: VCard4): seq[typeName] =
result = findAll[typeName](vc4.content)
result.add(funcDef)
macro genNameAccessors(propNames: static[seq[VC4_PropertyName]]): untyped =
result = genAstOpt({kDirtyTemplate}):
func name*(p: VC4_Property): string =
if p of VC4_Unknown:
return cast[VC4_Unknown](p).name
let genericIfBlock = result[6][0]
let memSafePNs = propNames
let propNamesToProcess = (memSafePNs --> filter(it != pnUnknown))
for pn in propNamesToProcess:
let (enumName, typeName, _, _) = namesForProp(pn)
let genericCond = nnkElifExpr.newTree(
nnkInfix.newTree(ident("of"), ident("p"), typeName),
quote do: return $`enumName`)
genericIfBlock.add(genericCond)
# echo result.repr
macro genLanguageAccessors(props: static[openarray[VC4_PropertyName]]): untyped =
result = newStmtList()
for p in props:
var name = $p
if name.len > 1: name = name[0] & name[1..^1].toLower
let typeName = ident("VC4_" & name)
let langFunc = genAstOpt({kDirtyTemplate}, typeName):
func language*(prop: typeName): Option[string] =
let langParam = prop.params --> find(it.name == "LANGUAGE")
if langParam.isSome and langParam.get.values.len > 0:
return some(langParam.get.values[0])
else: return none[string]()
result.add(langFunc)
macro genPrefAccessors(props: static[openarray[VC4_PropertyName]]): untyped =
result = newStmtList()
for p in props:
var name = $p
if name.len > 1: name = name[0] & name[1..^1].toLower
let typeName = ident("VC4_" & name)
let prefFunc = genAstOpt({kDirtyTemplate}, typeName):
func pref*(prop: typeName): int =
let prefParam = prop.params --> find(it.name == "PREF")
if prefParam.isSome and prefParam.get.values.len > 0:
return parseInt(prefParam.get.values[0])
else: return 101
result.add(prefFunc)
macro genPidAccessors(props: static[openarray[VC4_PropertyName]]): untyped =
result = newStmtList()
for p in props:
var name = $p
if name.len > 1: name = name[0] & name[1..^1].toLower
let typeName = ident("VC4_" & name)
let pidFunc = genAstOpt({kDirtyTemplate}, typeName):
func pid*(prop: typeName): seq[PidValue] =
let pidParam = prop.params --> find(it.name == "PREF")
if pidParam.isSome: return parsePidValues(pidParam.get)
else: return @[]
result.add(pidFunc)
macro genTypeAccessors(props: static[openarray[VC4_PropertyName]]): untyped =
result = newStmtList()
for p in props:
var name = $p
if name.len > 1: name = name[0] & name[1..^1].toLower
let typeName = ident("VC4_" & name)
let typeFun = genAstOpt({kDirtyTemplate}, typeName):
func types*(prop: typeName): seq[string] =
let typeParam = prop.params --> find(it.name == "TYPE")
if typeParam.isSome: return typeParam.get.values
else: return @[]
result.add(typeFun)
func inPrefOrder*[T: VC4_Property](props: seq[T]): seq[T] =
return props.sorted(vcard4.cmp[T])
func altId*(p: VC4_Property): Option[string] =
p.params.getSingleValue("ALTID")
func valueType*(p: VC4_Property): Option[string] =
p.params.getSingleValue("VALUE")
func allAlternatives*[T](vc4: VCard4): Table[string, seq[T]] =
result = initTable[string, seq[T]]()
for p in vc4.content:
if p of T:
let altId =
if p.altId.isSome: p.altId.get
else: ""
if not result.contains(altId): result[altId] = @[cast[T](p)]
else: result[altId].add(cast[T](p))
genPropAccessors(propertyCardMap.pairs.toSeq -->
filter(not [pnVersion, pnUnknown].contains(it[0])))
genNameAccessors(toSeq(VC4_PropertyName))
func customProp*(vc4: VCard4, name: string): seq[VC4_Unknown] =
result = vc4.content -->
filter(it of VC4_Unknown and it.name == name).
map(cast[VC4_Unknown](it))
genLanguageAccessors(supportedParams["LANGUAGE"].toSeq())
genPidAccessors(supportedParams["PID"].toSeq())
genPrefAccessors(supportedParams["PREF"].toSeq())
genTypeAccessors(supportedParams["TYPE"].toSeq())
# Setters
# =============================================================================
func set*[T: VC4_Property](vc4: VCard4, content: varargs[T]): void =
for c in content:
var nc = c
let existingIdx = vc4.content.indexOfIt(it of T)
if existingIdx < 0:
nc.propertyId = vc4.takePropertyId
vc4.content.add(nc)
else:
nc.propertyId = vc4.content[existingIdx].propertyId
vc4.content[existingIdx] = nc
func add*[T: VC4_Property](vc4: VCard4, content: varargs[T]): void =
for c in content:
var nc = c
nc.propertyId = vc4.takePropertyId
vc4.content.add(nc)
func updateOrAdd*[T: VC4_Property](vc4: VCard4, content: seq[T]): VCard4 =
for c in content:
let existingIdx = vc4.content.indexOfIt(it.propertyId == c.propertyId)
if existingIdx < 0: vc4.content.add(c)
else: c.content[existingIdx] = c
# Ouptut
# =============================================================================
func nameWithGroup(s: VC4_Property): string =
if s.group.isSome: s.group.get & "." & s.name
else: s.name
macro genSerializers(
props: static[openarray[(VC4_PropertyName, VC4_ValueType)]]
): untyped =
result = newStmtList()
for (pn, pt) in props:
let (enumName, typeName, _, _) = namesForProp(pn)
case pt
of vtText, vtTextOrUri, vtUri, vtDateTimeOrText:
let funcDef = genAstOpt({kDirtyTemplate}, enumName, typeName):
func serialize*(p: typeName): string =
result =
p.nameWithGroup &
serialize(p.params) &
":" & serializeValue(p.value)
result.add(funcDef)
of vtTextList:
let funcDef = genAstOpt({kDirtyTemplate}, enumName, typeName):
func serialize*(p: typeName): string =
result = p.nameWithGroup & serialize(p.params) &
serialize(p.params) & ":" &
(p.value --> map(serializeValue(it))).join(",")
result.add(funcDef)
else:
raise newException(ValueError, "serializer for " & $pn &
" properties must be hand-written")
macro genGenericSerializer(props: static[openarray[VC4_PropertyName]]): untyped =
result = genAstOpt({kDirtyTemplate}):
func serialize*(c: VC4_Property): string
let ifExpr = nnkIfExpr.newTree()
for p in props:
let (_, typeName, _, _) = namesForProp(p)
let returnStmt = genAstOpt({kDirtyTemplate}, typeName):
return serialize(cast[typeName](c))
ifExpr.add(nnkElifBranch.newTree(
nnkInfix.newTree(ident("of"), ident("c"), typeName),
returnStmt))
result[6] = newStmtList(ifExpr)
func serializeParamValue(value: string): string =
result = value.multiReplace([("\n", "^n"), ("^", "^^"), ("\"", "^'")])
for c in result:
if not SAFE_CHARS.contains(c):
result = "\"" & result & "\""
break
func serialize(params: seq[VC_Param]): string =
result = ""
for pLent in params:
let p = pLent
result &= ";" & p.name & "=" & (p.values -->
map(serializeParamValue(it))).join(",")
func serializeValue(value: string): string =
result = value.multiReplace(
[(",", "\\,"), (";", "\\;"), ("\\", "\\\\"),("\n", "\\n")])
func serialize*(n: VC4_N): string =
result = "N" & serialize(n.params) & ":" &
(n.family --> map(serializeValue(it))).join(",") & ";" &
(n.given --> map(serializeValue(it))).join(",") & ";" &
(n.additional --> map(serializeValue(it))).join(",") & ";" &
(n.prefixes --> map(serializeValue(it))).join(",") & ";" &
(n.suffixes --> map(serializeValue(it))).join(",")
func serialize*(a: VC4_Adr): string =
result = "ADR" & serialize(a.params) & ":" &
a.poBox & ";" & a.ext & ";" & a.street & ";" & a.locality & ";" &
a.region & ";" & a.postalCode & ";" & a.country
func serialize*(g: VC4_Gender): string =
result = "GENDER" & serialize(g.params) & ":"
if g.sex.isSome: result &= $g.sex.get
if g.genderIdentity.isSome: result &= ";" & g.genderIdentity.get
func serialize*(r: VC4_Rev): string =
result = "REV" & serialize(r.params) &
":" & r.value.format(TIMESTAMP_FORMATS[0])
func serialize*(c: VC4_ClientPidMap): string =
result = "CLIENTPIDMAP" & serialize(c.params) & ":" & $c.id & ";" & c.uri
genSerializers(fixedValueTypeProperties.toSeq & @[(pnUnknown, vtText)])
genGenericSerializer(toSeq(VC4_PropertyName))
func `$`*(pid: PidValue): string = $pid.propertyId & "." & $pid.sourceId
func `$`*(vc4: VCard4): string =
result = "BEGIN:VCARD" & CRLF
result &= "VERSION:4.0" & CRLF
for p in (vc4.content --> filter(not (it of VC4_Version))):
result &= foldContentLine(serialize(p)) & CRLF
result &= "END:VCARD" & CRLF
# Parsing
# =============================================================================
proc readParamValue(p: var VCardParser): string =
## Read a single parameter value at the current read position or error. Note
## that this implementation differs from RFC 6450 in two important ways:
## 1. It implements the escaping logic defined in RFC 6868 to allow newlines,
## and double-quote characters to be represented in parameter values.
## 2. It ALWAYS treats ',' as value delimiters when outside quoted values
## and NEVER treats them as delimiter within quoted values. This has been
## mentioned in several errata filed for RCF 6350, but no updated document
## has been released since. A decision is required in order to make the
## parsing logic unambigous.
result = newStringOfCap(32)
let quoted = p.peek == '"'
if quoted: discard p.read
while (quoted and QSAFE_CHARS.contains(p.peek)) or
(not quoted and SAFE_CHARS.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
of '\'':
result.add('"')
discard p.read
else:
p.error("invalid character escape: '^$1'" % [$p.read])
else: result.add(c)
if quoted and p.read != '"':
p.error("quoted parameter value expected to end with a " &
"double quote (\")")
proc readParams(p: var VCardParser): seq[VC_Param] =
result = @[]
while p.peek == ';':
discard p.read
var param: VC_Param = (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 readComponentValue(
p: var VCardParser,
ignorePrefix: set[char] = {},
requiredPrefix = none[char]()): string =
## Read a component value (defined by RFC6350) from the current read
## position. This is very similar to readTextValue. The difference is that
## component values cannot contain unescaped semicolons
result = newStringOfCap(32)
if requiredPrefix.isSome:
if p.peek != requiredPrefix.get:
p.error("expected to read '" & $requiredPrefix.get & "' but found '" &
$p.read & "'")
discard p.read
if ignorePrefix.contains(p.peek): discard p.read
while COMPONENT_CHARS.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 readComponentValueList(
p: var VCardParser,
seps: set[char] = {','},
requiredPrefix = none[char]()
): seq[string] =
## Read in a list of multiple component values (defined by RFC2426) from the
## current read position. This is used, for example, for the N and ADR
## properties.
if requiredPrefix.isSome:
if p.peek != requiredPrefix.get:
p.error("expected to read '" & $requiredPrefix.get & "' but found '" &
$p.read & "'")
discard p.read
result = @[p.readComponentValue]
while seps.contains(p.peek): result.add(p.readComponentValue(ignorePrefix = seps))
proc readTextValue(p: var VCardParser, ignorePrefix: set[char] = {}): string =
## Read a text-value (defined by RFC6350) from the current read position.
result = newStringOfCap(32)
if ignorePrefix.contains(p.peek): discard p.read
while TEXT_CHARS.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] = {','},
requiredPrefix = 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, for the N and ADR
## properties.
if requiredPrefix.isSome:
if p.peek != requiredPrefix.get:
p.error("expected to read '" & $requiredPrefix.get & "' but found '" &
$p.read & "'")
discard p.read
result = @[p.readTextValue]
while seps.contains(p.peek): result.add(p.readTextValue(ignorePrefix = seps))
macro genPropParsers(
genProps: static[openarray[(VC4_PropertyName, VC4_ValueType)]],
group: Option[string],
name: string,
params: seq[VC_Param],
contents: var seq[VC4_Property],
p: var VCardParser
): untyped =
macro ac(value: untyped): untyped =
# Assign Common parameter values found on all properties
result = value
result.add(newTree(nnkExprColonExpr, ident("group"), ident("group")))
result.add(newTree(nnkExprColonExpr, ident("params"), ident("params")))
# echo result.treeRepr
result = nnkCaseStmt.newTree(quote do:
parseEnum[VC4_PropertyName](name, pnUnknown))
for (pn, pt) in genProps:
let (enumName, typeName, initFuncName, _) = namesForProp(pn)
let parseCase = nnkOfBranch.newTree(quote do: `enumName`, newEmptyNode())
result.add(parseCase)
case pt
of vtDateTimeOrText:
parseCase[1] = genAst(contents, initFuncName, typeName, p):
let valueType = params.getSingleValue("VALUE")
if valueType.isSome and valueType.get != $vtDateAndOrTime and
valueType.get != $vtText:
p.error("VALUE must be either \"date-and-or-time\" or \"text\" for " &
name & " properties.")
contents.add(initFuncName(
value = p.readValue,
valueType = valueType,
group = group,
params = params))
of vtText:
parseCase[1] = genAst(contents, typeName, pt):
p.validateType(params, pt)
contents.add(ac(typeName(value: p.readTextValue)))
of vtTextList:
parseCase[1] = genAst(contents, typeName):
p.validateType(params, vtText)
contents.add(ac(typeName(value: p.readTextValueList)))
of vtTextOrUri:
parseCase[1] = genAst(contents, typeName):
let valueType = params.getSingleValue("VALUE")
if valueType.isNone or valueType.get == $vtUri:
contents.add(ac(typeName(value: p.readValue)))
elif valueType.isSome and valueType.get == $vtText:
contents.add(ac(typeName(value: p.readTextValue)))
else:
p.error(("VALUE must be either \"text\" or \"uri\" for $# " &
"properties (was $#).") % [name, $valueType])
of vtUri:
parseCase[1] = genAst(typeName, contents, pt):
p.validateType(params, pt)
contents.add(ac(typeName(value: p.readValue)))
else:
raise newException(ValueError, "parse statements for for " & $pn &
" properties must be hand-written")
block: # N
let parseCase = nnkOfBranch.newTree(ident("pnN"), newEmptyNode())
result.add(parseCase)
parseCase[1] = genAst(contents):
p.validateType(params, vtText)
contents.add(ac(VC4_N(
family: p.readComponentValueList,
given: p.readComponentValueList(requiredPrefix = some(';')),
additional: p.readComponentValueList(requiredPrefix = some(';')),
prefixes: p.readComponentValueList(requiredPrefix = some(';')),
suffixes: p.readComponentValueList(requiredPrefix = some(';')))))
block: # GENDER
let parseCase = nnkOfBranch.newTree(ident("pnGender"), newEmptyNode())
result.add(parseCase)
parseCase[1] = genAst(contents):
p.validateType(params, vtText)
var sex: Option[VC4_Sex] = none[VC4_Sex]()
let sexCh = p.read
if {'M', 'F', 'O', 'N', 'U'}.contains(sexCh):
sex = some(parseEnum[VC4_Sex]($sexCh))
elif sexCh != ';':
p.error("The sex component of the GENDER property must be one of " &
"M, F, O, N, or U (it was " & $sexCh & ")")
contents.add(ac(VC4_Gender(
sex: sex,
genderIdentity:
if sexCh == ';' or p.peek == ';':
some(p.readTextValue(ignorePrefix = {';'}))
else: none[string]())))
block: # ADR
let parseCase = nnkOfBranch.newTree(ident("pnAdr"), newEmptyNode())
result.add(parseCase)
parseCase[1] = genAst(contents):
p.validateType(params, vtText)
contents.add(ac(VC4_Adr(
poBox: p.readComponentValue,
ext: p.readComponentValue(requiredPrefix = some(';')),
street: p.readComponentValue(requiredPrefix = some(';')),
locality: p.readComponentValue(requiredPrefix = some(';')),
region: p.readComponentValue(requiredPrefix = some(';')),
postalCode: p.readComponentValue(requiredPrefix = some(';')),
country: p.readComponentValue(requiredPrefix = some(';')))))
block: # REV
let parseCase = nnkOfBranch.newTree(ident("pnRev"), newEmptyNode())
result.add(parseCase)
parseCase[1] = genAst(contents):
p.validateType(params, vtTimestamp)
contents.add(ac(VC4_Rev(value: parseTimestamp(p.readValue))))
block: # CLIENTPIDMAP
let parseCase = nnkOfBranch.newTree(ident("pnClientPidMap"), newEmptyNode())
result.add(parseCase)
parseCase[1] = genAst(contents):
contents.add(ac(VC4_ClientPidMap(
id: parseInt(p.readComponentValue),
uri: p.readTextValue(ignorePrefix = {';'}))))
block: # UNKNOWN
let parseCase = nnkOfBranch.newTree(ident("pnUnknown"), newEmptyNode())
result.add(parseCase)
parseCase[1] = genAst(contents):
contents.add(ac(VC4_Unknown(name: name, value: p.readValue)))
# echo result.repr
proc parseContentLines*(p: var VCardParser): seq[VC4_Property] =
result = @[]
while true:
let group = p.readGroup
let name = p.readName
if name == "END":
p.expect(":VCARD" & CRLF)
break
let params = p.readParams
p.expect(":")
genPropParsers(fixedValueTypeProperties, group, name, params, result, p)
p.expect(CRLF)
# Private Function Unit Tests
# ============================================================================
proc runVCard4PrivateTests*() =
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"
# "all property types defined"
block:
assert declared(VC4_Source)
assert declared(VC4_Kind)
assert declared(VC4_Xml)
assert declared(VC4_Fn)
assert declared(VC4_N)
assert declared(VC4_Nickname)
assert declared(VC4_Photo)
assert declared(VC4_Bday)
assert declared(VC4_Anniversary)
assert declared(VC4_Gender)
assert declared(VC4_Adr)
assert declared(VC4_Tel)
assert declared(VC4_Email)
assert declared(VC4_Impp)
assert declared(VC4_Lang)
assert declared(VC4_Tz)
assert declared(VC4_Geo)
assert declared(VC4_Title)
assert declared(VC4_Role)
assert declared(VC4_Logo)
assert declared(VC4_Org)
assert declared(VC4_Member)
assert declared(VC4_Related)
assert declared(VC4_Categories)
assert declared(VC4_Note)
assert declared(VC4_Prodid)
assert declared(VC4_Rev)
assert declared(VC4_Sound)
assert declared(VC4_Uid)
assert declared(VC4_Clientpidmap)
assert declared(VC4_Url)
assert declared(VC4_Version)
assert declared(VC4_Key)
assert declared(VC4_Fburl)
assert declared(VC4_Caladruri)
assert declared(VC4_Caluri)
# "all property initializers defined"
block:
assert declared(new_VC4_Source)
assert declared(new_VC4_Kind)
assert declared(new_VC4_Xml)
assert declared(new_VC4_Fn)
assert declared(new_VC4_N)
assert declared(new_VC4_Nickname)
assert declared(new_VC4_Photo)
assert declared(new_VC4_Bday)
assert declared(new_VC4_Anniversary)
assert declared(new_VC4_Gender)
assert declared(new_VC4_Adr)
assert declared(new_VC4_Tel)
assert declared(new_VC4_Email)
assert declared(new_VC4_Impp)
assert declared(new_VC4_Lang)
assert declared(new_VC4_Tz)
assert declared(new_VC4_Geo)
assert declared(new_VC4_Title)
assert declared(new_VC4_Role)
assert declared(new_VC4_Logo)
assert declared(new_VC4_Org)
assert declared(new_VC4_Member)
assert declared(new_VC4_Related)
assert declared(new_VC4_Categories)
assert declared(new_VC4_Note)
assert declared(new_VC4_Prodid)
assert declared(new_VC4_Rev)
assert declared(new_VC4_Sound)
assert declared(new_VC4_Uid)
assert declared(new_VC4_Clientpidmap)
assert declared(new_VC4_Url)
assert declared(new_VC4_Version)
assert declared(new_VC4_Key)
assert declared(new_VC4_Fburl)
assert declared(new_VC4_Caladruri)
assert declared(new_VC4_Caluri)