Move whole-card cardinality checks into overloaded validate helpers in the vCard 3 and vCard 4 modules, and have readVCard invoke those validators after parsing instead of hard-coding property checks in the shared parser. For vCard 3, validate property cardinality directly from propertyCardMap and add regression coverage for duplicate single-cardinality properties. For vCard 4, validate from propertyCardMap as well, while preserving ALTID semantics for single-cardinality properties so alternate representations remain valid and duplicate ungrouped properties are rejected. Rework VERSION handling to treat it as parser metadata rather than stored card content. Parsing still requires exactly one canonical VERSION line and rejects missing or duplicate occurrences, but parsed cards no longer retain VC3_Version or VC4_Version objects in content. Accessors and serializers continue to expose and emit the canonical version for the concrete card type, which keeps parsed and programmatically constructed cards on the same model. Update the vCard 3 and vCard 4 test suites to cover the new validation path, confirm VERSION is not persisted in content, and adjust fixture expectations to match the canonical VERSION model. AI-Assisted: yes AI-Tool: OpenAI Codex / gpt-5.4 xhigh
1553 lines
48 KiB
Nim
1553 lines
48 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_Property](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())
|
|
|
|
func countCardinalityInstances(vc4: VCard4, propName: string): int =
|
|
var altIds = initHashSet[string]()
|
|
|
|
for p in vc4.content:
|
|
if p.name != propName:
|
|
continue
|
|
|
|
if p.altId.isSome:
|
|
altIds.incl(p.altId.get)
|
|
else:
|
|
inc result
|
|
|
|
result += altIds.len
|
|
|
|
func validate*(vc4: VCard4): void =
|
|
## Validate property cardinality for a vCard 4.0 card.
|
|
for pn in VC4_PropertyName:
|
|
if pn == pnVersion:
|
|
continue
|
|
|
|
if not propertyCardMap.contains(pn):
|
|
continue
|
|
|
|
let rawCount = vc4.content.countIt(it.name == $pn)
|
|
let cardinalityCount =
|
|
case propertyCardMap[pn]
|
|
of vpcExactlyOne, vpcAtMostOne:
|
|
vc4.countCardinalityInstances($pn)
|
|
of vpcAtLeastOne, vpcAny:
|
|
rawCount
|
|
|
|
case propertyCardMap[pn]
|
|
of vpcExactlyOne:
|
|
if cardinalityCount != 1:
|
|
raise newException(ValueError,
|
|
"VCard should have exactly one $# property, but $# were found" %
|
|
[$pn, $cardinalityCount])
|
|
of vpcAtLeastOne:
|
|
if cardinalityCount < 1:
|
|
raise newException(ValueError,
|
|
"VCard should have at least one $# property, but $# were found" %
|
|
[$pn, $cardinalityCount])
|
|
of vpcAtMostOne:
|
|
if cardinalityCount > 1:
|
|
raise newException(ValueError,
|
|
("VCard should have at most one $# property, but $# " &
|
|
"distinct properties were found") % [$pn, $cardinalityCount])
|
|
of vpcAny:
|
|
discard
|
|
|
|
# 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 = @[]
|
|
var sawVersion = false
|
|
|
|
while true:
|
|
let group = p.readGroup
|
|
let name = p.readName
|
|
if name == "END":
|
|
p.expect(":VCARD" & CRLF)
|
|
break
|
|
let params = p.readParams
|
|
p.expect(":")
|
|
|
|
if name == $pnVersion:
|
|
if sawVersion:
|
|
p.error("VCard should have exactly one VERSION property, but 2 were found")
|
|
p.validateType(params, vtText)
|
|
p.expect("4.0")
|
|
sawVersion = true
|
|
else:
|
|
genPropParsers(fixedValueTypeProperties, group, name, params, result, p)
|
|
|
|
p.expect(CRLF)
|
|
|
|
if not sawVersion:
|
|
p.error("VCard should have exactly one VERSION property, but 0 were found")
|
|
|
|
|
|
# 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)
|