Compare commits

...

29 Commits

Author SHA1 Message Date
fc8b069af0 Close opened file handles from the vCard lexer. 2026-03-29 07:27:04 -05:00
0e4198933a Add the option to disable validation when reading vCards. 2026-03-29 07:26:47 -05:00
331502b6b8 Include vcard-test-suite submodule. 2026-03-28 22:52:35 -05:00
8e17a96145 Add typed vCard 4 reduced-precision date constructors 2026-03-28 22:51:41 -05:00
d968486473 Fix vCard 4 REV timestamp parsing 2026-03-28 21:43:55 -05:00
d1318acbf9 Add vCard 4 REV and CLIENTPIDMAP tests 2026-03-28 21:42:43 -05:00
addb2a2d8d Validate vCard 4 MEMBER and PID semantics 2026-03-28 21:41:55 -05:00
ac25a4ec06 Add vCard 4 semantic validation tests 2026-03-28 21:40:15 -05:00
45e530a4ca Validate vCard 4 parameter applicability 2026-03-28 21:39:03 -05:00
15d2b7ecd0 Add vCard 4 parameter applicability tests 2026-03-28 21:37:54 -05:00
04392ac203 Expose vCard 4 SORT-AS and CALSCALE 2026-03-28 21:36:26 -05:00
45ec8efc8a Add vCard 4 SORT-AS and CALSCALE tests 2026-03-28 21:35:44 -05:00
2379196b0b Expose vCard 4 ADR parameters 2026-03-28 21:34:04 -05:00
eb6d21c9bf Add vCard 4 ADR parameter tests 2026-03-28 21:33:37 -05:00
bbef5ed92d Handle unknown RFC 6868 escapes in vCard 4 params 2026-03-28 21:31:58 -05:00
b1cf3bb867 Add vCard 4 RFC 6868 regression tests 2026-03-28 21:31:10 -05:00
6012989432 Implement vCard 4 ORG structured values 2026-03-28 20:51:11 -05:00
5cdffd9126 Add vCard 4 ORG structured-property tests 2026-03-28 20:50:08 -05:00
4f050b9068 Implement vCard 4 ADR structured values 2026-03-28 20:49:33 -05:00
4bc76fa2f8 Add vCard 4 ADR structured-property tests 2026-03-28 20:48:19 -05:00
90e746abdd Support vCard 4 TZ alternate value types 2026-03-28 20:28:22 -05:00
7e933dd30f Support vCard 4 LANG language-tag values 2026-03-28 20:28:00 -05:00
99a36f71d0 Add vCard 4 LANG and TZ value-type tests 2026-03-28 20:27:12 -05:00
8ea67163e7 Fix vCard 4 PID accessors 2026-03-28 20:23:46 -05:00
35e191d82b Round-trip vCard 4 URI media types 2026-03-28 20:23:31 -05:00
c2f194bdc2 Emit VALUE=text for vCard 4 date text values 2026-03-28 20:23:06 -05:00
4221b3af7c Preserve vCard 4 group prefixes in serializers 2026-03-28 20:22:46 -05:00
d3d5f46096 Fix vCard 4 text-list serialization 2026-03-28 20:22:23 -05:00
6b1dceb2cd Fix vCard 4 text-or-uri constructors 2026-03-28 20:22:00 -05:00
5 changed files with 805 additions and 63 deletions

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "vcard-test-suite"]
path = vcard-test-suite
url = https://gitlab.com/pwithnall/vcard-test-suite.git

View File

@@ -27,7 +27,7 @@ proc add*[T](vc: VCard, content: varargs[T]): void =
if vc.parsedVersion == VCardV3: add(cast[VCard3](vc), content)
else: add(cast[VCard4](vc), content)
proc readVCard*(p: var VCardParser): VCard =
proc readVCard*(p: var VCardParser, validate = true): VCard =
# Read the preamble
discard p.readGroup
p.expect("begin:vcard" & CRLF)
@@ -55,6 +55,7 @@ proc readVCard*(p: var VCardParser): VCard =
if result.parsedVersion == VCardV3:
while (p.skip(CRLF, true)): discard
if validate:
try:
if result.parsedVersion == VCardV3:
cast[VCard3](result).validate()
@@ -64,21 +65,31 @@ proc readVCard*(p: var VCardParser): VCard =
p.error(exc.msg)
proc initVCardParser*(input: Stream, filename = "input"): VCardParser =
## Note: the returned VCardParser object will have an open file handle. Make
## sure to call `close` on the returned VCardParser to avoid file handle
## leaks.
result.filename = filename
lexer.open(result, input)
proc initVCardParser*(content: string, filename = "input"): VCardParser =
## Note: the returned VCardParser object will have an open file handle. Make
## sure to call `close` on the returned VCardParser to avoid file handle
## leaks.
initVCardParser(newStringStream(content), filename)
proc initVCardParserFromFile*(filepath: string): VCardParser =
## Note: the returned VCardParser object will have an open file handle. Make
## sure to call `close` on the returned VCardParser to avoid file handle
## leaks.
initVCardParser(newFileStream(filepath, fmRead), filepath)
proc parseVCards*(input: Stream, filename = "input"): seq[VCard] =
proc parseVCards*(input: Stream, filename = "input", validate = true): seq[VCard] =
var p = initVCardParser(input, filename)
while p.peek != '\0': result.add(p.readVCard)
defer: p.close()
while p.peek != '\0': result.add(p.readVCard(validate))
proc parseVCards*(content: string, filename = "input"): seq[VCard] =
parseVCards(newStringStream(content), filename)
proc parseVCards*(content: string, filename = "input", validate = true): seq[VCard] =
parseVCards(newStringStream(content), filename, validate)
proc parseVCardsFromFile*(filepath: string): seq[VCard] =
parseVCards(newFileStream(filepath, fmRead), filepath)
proc parseVCardsFromFile*(filepath: string, validate = true): seq[VCard] =
parseVCards(newFileStream(filepath, fmRead), filepath, validate)

View File

@@ -136,7 +136,6 @@ const fixedValueTypeProperties = [
(pnTitle, vtText),
(pnRole, vtText),
(pnLogo, vtUri),
(pnOrg, vtText),
(pnMember, vtUri),
(pnRelated, vtTextOrUri),
(pnCategories, vtTextList),
@@ -172,13 +171,23 @@ const supportedParams: Table[string, HashSet[VC4_PropertyName]] = [
pnTz, pnGeo, pnTitle, pnRole, pnLogo, pnOrg, pnRelated, pnCategories,
pnNote, pnSound, pnUrl, pnKey, pnFburl, pnCaladrUri, pnCalUri ].toHashSet),
("CALSCALE", @[pnBday, pnAnniversary].toHashSet),
("SORT-AS", @[pnN, pnOrg].toHashSet),
("GEO", @[pnAdr].toHashSet),
("TZ", @[pnAdr].toHashSet),
("LABEL", @[pnAdr].toHashSet),
].toTable
const TIMESTAMP_FORMATS = [
"yyyyMMdd'T'hhmmssZZZ",
"yyyyMMdd'T'hhmmssZZ",
"yyyyMMdd'T'hhmmssZ",
"yyyyMMdd'T'hhmmss"
"yyyyMMdd'T'HHmmssZZZ",
"yyyyMMdd'T'HHmmssZZ",
"yyyyMMdd'T'HHmmssZ",
"yyyyMMdd'T'HHmmss"
]
const TEXT_CHARS = WSP + NON_ASCII + { '\x21'..'\x2B', '\x2D'..'\x7E' }
@@ -334,14 +343,17 @@ type
sex*: Option[VC4_Sex]
genderIdentity*: Option[string]
VC4_Org* = ref object of VC4_Property
value*: seq[string]
VC4_Adr* = ref object of VC4_Property
poBox*: string
ext*: string
street*: string
locality*: string
region*: string
postalCode*: string
country*: string
poBox*: seq[string]
ext*: seq[string]
street*: seq[string]
locality*: seq[string]
region*: seq[string]
postalCode*: seq[string]
country*: seq[string]
VC4_ClientPidMap* = ref object of VC4_Property
id*: int
@@ -381,6 +393,92 @@ func flattenParameters(
result = @[]
for k, v in paramTable.pairs: result.add((k, v))
func asComponentList(value: string): seq[string] =
if value.len > 0:
@[value]
else:
@[]
func normalizeStructuredParamValues(values: seq[string]): seq[string] =
if values.len == 1 and values[0].contains(","):
values[0].split(",")
else:
values
func padInt(value, width: int): string =
($value).align(width, '0')
func formatTypedDateAndOrTimeValue(
year: Option[int] = none[int](),
month: Option[int] = none[int](),
day: Option[int] = none[int](),
hour: Option[int] = none[int](),
minute: Option[int] = none[int](),
second: Option[int] = none[int](),
timezone: Option[string] = none[string]()
): string =
if year.isSome and year.get < 0:
raise newException(ValueError, "year must be non-negative")
if month.isSome and (month.get < 1 or month.get > 12):
raise newException(ValueError, "month must be between 1 and 12")
if day.isSome and (day.get < 1 or day.get > 31):
raise newException(ValueError, "day must be between 1 and 31")
if hour.isSome and (hour.get < 0 or hour.get > 23):
raise newException(ValueError, "hour must be between 0 and 23")
if minute.isSome and (minute.get < 0 or minute.get > 59):
raise newException(ValueError, "minute must be between 0 and 59")
if second.isSome and (second.get < 0 or second.get > 59):
raise newException(ValueError, "second must be between 0 and 59")
if timezone.isSome and
not (timezone.get == "Z" or
(timezone.get.len == 5 and
{'+', '-'}.contains(timezone.get[0]) and
timezone.get[1..^1].allCharsInSet(DIGIT))):
raise newException(ValueError,
"timezone must be 'Z' or an RFC 6350 numeric UTC offset")
let hasDate = year.isSome or month.isSome or day.isSome
let hasTime = hour.isSome or minute.isSome or second.isSome or timezone.isSome
if not hasDate and not hasTime:
raise newException(ValueError,
"at least one date-and-or-time component must be provided")
if timezone.isSome and second.isNone:
raise newException(ValueError,
"timezone requires a second component for RFC 6350 date-and-or-time values")
if year.isSome:
result &= padInt(year.get, 4)
elif month.isSome or day.isSome:
result &= "--"
if month.isSome:
result &= padInt(month.get, 2)
elif day.isSome:
result &= "-"
if day.isSome:
result &= padInt(day.get, 2)
if hasTime:
result &= "T"
if hour.isSome:
result &= padInt(hour.get, 2)
elif minute.isSome or second.isSome or timezone.isSome:
result &= "-"
if minute.isSome:
result &= padInt(minute.get, 2)
elif second.isSome or timezone.isSome:
result &= "-"
if second.isSome:
result &= padInt(second.get, 2)
if timezone.isSome:
result &= timezone.get
proc parseDateAndOrTime[T](
prop: var T,
value: string
@@ -448,7 +546,7 @@ proc parseDateAndOrTime[T](
proc parseTimestamp(value: string): DateTime =
for fmt in TIMESTAMP_FORMATS:
try: return value.parse(fmt)
try: return value.parse(fmt, utc())
except: discard
raise newException(VCardParsingError, "unable to parse timestamp value: " & value)
@@ -470,6 +568,29 @@ func parsePidValues(param: VC_Param): seq[PidValue]
template validateType(p: VCardParser, params: seq[VC_Param], t: VC4_ValueType) =
p.validateRequiredParameters(params, [("VALUE", $t)])
proc validateParamApplicability(
p: VCardParser,
name: string,
params: seq[VC_Param]
) =
let pn = parseEnum[VC4_PropertyName](name, pnUnknown)
if pn == pnUnknown:
return
let valueType = params.getSingleValue("VALUE")
for param in params:
let pname = param.name.toUpper
if pname == "VALUE" or pname == "ALTID":
continue
if supportedParams.contains(pname) and not supportedParams[pname].contains(pn):
p.error("parameter '$1' is not allowed on property '$2'" % [pname, name])
if pname == "CALSCALE" and valueType.isSome and valueType.get == $vtText:
p.error("parameter 'CALSCALE' is not allowed when VALUE=text")
func cmp[T: VC4_Property](x, y: T): int =
return cmp(x.pref, y.pref)
@@ -575,11 +696,13 @@ macro genDateTimeOrTextPropInitializers(
let datetimeFuncDef = genAstOpt({kDirtyTemplate}, enumName, initFuncName, typeName):
func initFuncName*(
value: DateTime,
calscale: Option[string] = none[string](),
altId: Option[string] = none[string](),
group: Option[string] = none[string](),
params: seq[VC_Param] = @[]): typeName =
return typeName(
params: flattenParameters(params,
("CALSCALE", if calscale.isSome: @[calscale.get] else: @[]),
("ALTID", if altId.isSome: @[altId.get] else: @[])),
group: group,
value: value.format(TIMESTAMP_FORMATS[0]),
@@ -596,12 +719,17 @@ macro genDateTimeOrTextPropInitializers(
proc initFuncName*(
value: string,
valueType: Option[string] = some($vtDateAndOrTime),
calscale: Option[string] = none[string](),
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: @[])),
("CALSCALE", if calscale.isSome: @[calscale.get] else: @[]),
("ALTID", if altId.isSome: @[altId.get] else: @[]),
("VALUE",
if valueType.isSome and valueType.get == $vtText: @[$vtText]
else: @[])),
group: group,
value: value,
valueType: vtText)
@@ -677,7 +805,8 @@ macro genTextOrUriPropInitializers(
params: flattenParameters(params,
("ALTID", if altId.isSome: @[altId.get] else: @[])),
isUrl: isUrl,
group: group)
group: group,
value: value)
addConditionalParams(prop, funcDef)
result.add(funcDef)
@@ -698,7 +827,8 @@ macro genUriPropInitializers(
params: seq[VC_Param] = @[]): typeName =
return typeName(
params: flattenParameters(params,
("ALTID", if altId.isSome: @[altId.get] else: @[])),
("ALTID", if altId.isSome: @[altId.get] else: @[]),
("MEDIATYPE", if mediaType.isSome: @[mediaType.get] else: @[])),
group: group,
mediaType: mediaType,
value: value)
@@ -721,18 +851,74 @@ genTextOrUriPropInitializers(fixedValueTypeProperties -->
genUriPropInitializers(fixedValueTypeProperties -->
filter(it[1] == vtUri).map(it[0]))
proc newVC4_Bday*(
year: Option[int] = none[int](),
month: Option[int] = none[int](),
day: Option[int] = none[int](),
hour: Option[int] = none[int](),
minute: Option[int] = none[int](),
second: Option[int] = none[int](),
timezone: Option[string] = none[string](),
calscale: Option[string] = none[string](),
altId: Option[string] = none[string](),
group: Option[string] = none[string](),
params: seq[VC_Param] = @[]): VC4_Bday =
return newVC4_Bday(
value = formatTypedDateAndOrTimeValue(
year = year,
month = month,
day = day,
hour = hour,
minute = minute,
second = second,
timezone = timezone),
calscale = calscale,
altId = altId,
group = group,
params = params)
proc newVC4_Anniversary*(
year: Option[int] = none[int](),
month: Option[int] = none[int](),
day: Option[int] = none[int](),
hour: Option[int] = none[int](),
minute: Option[int] = none[int](),
second: Option[int] = none[int](),
timezone: Option[string] = none[string](),
calscale: Option[string] = none[string](),
altId: Option[string] = none[string](),
group: Option[string] = none[string](),
params: seq[VC_Param] = @[]): VC4_Anniversary =
return newVC4_Anniversary(
value = formatTypedDateAndOrTimeValue(
year = year,
month = month,
day = day,
hour = hour,
minute = minute,
second = second,
timezone = timezone),
calscale = calscale,
altId = altId,
group = group,
params = params)
func newVC4_N*(
family: seq[string] = @[],
given: seq[string] = @[],
additional: seq[string] = @[],
prefixes: seq[string] = @[],
suffixes: seq[string] = @[],
sortAs: 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,
("SORT-AS", sortAs),
("ALTID", if altId.isSome: @[altId.get] else: @[]))),
group, family, given, additional, prefixes, suffixes)
@@ -748,14 +934,60 @@ func newVC4_Gender*(
("ALTID", if altId.isSome: @[altId.get] else: @[]))),
sex, genderIdentity, group)
func newVC4_Org*(
value: seq[string],
sortAs: seq[string] = @[],
altId: Option[string] = none[string](),
group: Option[string] = none[string](),
language: Option[string] = none[string](),
params: seq[VC_Param] = @[],
pids: seq[PidValue] = @[],
pref: Option[int] = none[int](),
types: seq[string] = @[]): VC4_Org =
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_Org(params: flattenParameters(params,
("SORT-AS", sortAs),
("ALTID", if altId.isSome: @[altId.get] else: @[]),
("LANGUAGE", if language.isSome: @[language.get] else: @[]),
("PID", pids --> map($it)),
("PREF", if pref.isSome: @[$pref.get] else: @[]),
("TYPE", types))),
value, group)
func newVC4_Org*(
value: string,
sortAs: seq[string] = @[],
altId: Option[string] = none[string](),
group: Option[string] = none[string](),
language: Option[string] = none[string](),
params: seq[VC_Param] = @[],
pids: seq[PidValue] = @[],
pref: Option[int] = none[int](),
types: seq[string] = @[]): VC4_Org =
return newVC4_Org(
value = asComponentList(value),
sortAs = sortAs,
altId = altId,
group = group,
language = language,
params = params,
pids = pids,
pref = pref,
types = types)
func newVC4_Adr*(
poBox = "",
ext = "",
street = "",
locality = "",
region = "",
postalCode = "",
country = "",
poBox: seq[string] = @[],
ext: seq[string] = @[],
street: seq[string] = @[],
locality: seq[string] = @[],
region: seq[string] = @[],
postalCode: seq[string] = @[],
country: seq[string] = @[],
altId: Option[string] = none[string](),
geo: Option[string] = none[string](),
group: Option[string] = none[string](),
@@ -782,6 +1014,44 @@ func newVC4_Adr*(
("TZ", if tz.isSome: @[tz.get] else: @[]))),
poBox, ext, street, locality, region, postalCode, country, 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 =
return newVC4_Adr(
poBox = asComponentList(poBox),
ext = asComponentList(ext),
street = asComponentList(street),
locality = asComponentList(locality),
region = asComponentList(region),
postalCode = asComponentList(postalCode),
country = asComponentList(country),
altId = altId,
geo = geo,
group = group,
label = label,
language = language,
params = params,
pids = pids,
pref = pref,
types = types,
tz = tz)
func newVC4_ClientPidMap*(
id: int,
uri: string,
@@ -911,7 +1181,7 @@ macro genPidAccessors(props: static[openarray[VC4_PropertyName]]): untyped =
let pidFunc = genAstOpt({kDirtyTemplate}, typeName):
func pid*(prop: typeName): seq[PidValue] =
let pidParam = prop.params --> find(it.name == "PREF")
let pidParam = prop.params --> find(it.name == "PID")
if pidParam.isSome: return parsePidValues(pidParam.get)
else: return @[]
result.add(pidFunc)
@@ -941,6 +1211,32 @@ func altId*(p: VC4_Property): Option[string] =
func valueType*(p: VC4_Property): Option[string] =
p.params.getSingleValue("VALUE")
func calscale*(prop: VC4_DateTimeOrTextProperty): Option[string] =
prop.params.getSingleValue("CALSCALE")
func sortAs*(prop: VC4_N): seq[string] =
let sortAsParam = prop.params --> find(it.name == "SORT-AS")
if sortAsParam.isSome:
normalizeStructuredParamValues(sortAsParam.get.values)
else:
@[]
func sortAs*(prop: VC4_Org): seq[string] =
let sortAsParam = prop.params --> find(it.name == "SORT-AS")
if sortAsParam.isSome:
normalizeStructuredParamValues(sortAsParam.get.values)
else:
@[]
func geo*(prop: VC4_Adr): Option[string] =
prop.params.getSingleValue("GEO")
func label*(prop: VC4_Adr): Option[string] =
prop.params.getSingleValue("LABEL")
func tz*(prop: VC4_Adr): Option[string] =
prop.params.getSingleValue("TZ")
func allAlternatives*[T](vc4: VCard4): Table[string, seq[T]] =
result = initTable[string, seq[T]]()
@@ -1018,6 +1314,49 @@ func validate*(vc4: VCard4): void =
of vpcAny:
discard
if vc4.member.len > 0:
if vc4.kind.isNone or vc4.kind.get.value.toLowerAscii != "group":
raise newException(ValueError,
"MEMBER properties require the KIND property to be set to 'group'")
var clientPidMaps = initTable[int, string]()
for clientPidMap in vc4.clientpidmap:
if clientPidMap.id <= 0:
raise newException(ValueError,
"CLIENTPIDMAP identifiers must be positive integers")
if clientPidMaps.contains(clientPidMap.id):
raise newException(ValueError,
"CLIENTPIDMAP identifier $# appears more than once" % [$clientPidMap.id])
clientPidMaps[clientPidMap.id] = clientPidMap.uri
var referencedSourceIds = initHashSet[int]()
for prop in vc4.content:
var pidParam = none[VC_Param]()
for param in prop.params:
if param.name == "PID":
pidParam = some(param)
break
if pidParam.isNone:
continue
let pidValues =
try:
parsePidValues(pidParam.get)
except VCardParsingError as exc:
raise newException(ValueError, exc.msg)
for pidValue in pidValues:
if pidValue.propertyId <= 0 or pidValue.sourceId <= 0:
raise newException(ValueError,
"PID identifiers must be positive integers")
referencedSourceIds.incl(pidValue.sourceId)
for sourceId in referencedSourceIds:
if not clientPidMaps.contains(sourceId):
raise newException(ValueError,
"PID source identifier $# is missing a matching CLIENTPIDMAP" %
[$sourceId])
# Setters
# =============================================================================
@@ -1073,8 +1412,7 @@ macro genSerializers(
of vtTextList:
let funcDef = genAstOpt({kDirtyTemplate}, enumName, typeName):
func serialize*(p: typeName): string =
result = p.nameWithGroup & serialize(p.params) &
serialize(p.params) & ":" &
result = p.nameWithGroup & serialize(p.params) & ":" &
(p.value --> map(serializeValue(it))).join(",")
result.add(funcDef)
@@ -1119,30 +1457,42 @@ func serializeValue(value: string): string =
result = value.multiReplace(
[(",", "\\,"), (";", "\\;"), ("\\", "\\\\"),("\n", "\\n")])
func serializeComponentList(values: seq[string]): string =
(values --> map(serializeValue(it))).join(",")
func serialize*(n: VC4_N): string =
result = "N" & serialize(n.params) & ":" &
result = n.nameWithGroup & 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*(o: VC4_Org): string =
result = o.nameWithGroup & serialize(o.params) & ":" &
(o.value --> 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
result = a.nameWithGroup & serialize(a.params) & ":" &
serializeComponentList(a.poBox) & ";" &
serializeComponentList(a.ext) & ";" &
serializeComponentList(a.street) & ";" &
serializeComponentList(a.locality) & ";" &
serializeComponentList(a.region) & ";" &
serializeComponentList(a.postalCode) & ";" &
serializeComponentList(a.country)
func serialize*(g: VC4_Gender): string =
result = "GENDER" & serialize(g.params) & ":"
result = g.nameWithGroup & 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) &
result = r.nameWithGroup & serialize(r.params) &
":" & r.value.format(TIMESTAMP_FORMATS[0])
func serialize*(c: VC4_ClientPidMap): string =
result = "CLIENTPIDMAP" & serialize(c.params) & ":" & $c.id & ";" & c.uri
result = c.nameWithGroup & serialize(c.params) & ":" & $c.id & ";" & c.uri
genSerializers(fixedValueTypeProperties.toSeq & @[(pnUnknown, vtText)])
genGenericSerializer(toSeq(VC4_PropertyName))
@@ -1189,7 +1539,10 @@ proc readParamValue(p: var VCardParser): string =
result.add('"')
discard p.read
else:
p.error("invalid character escape: '^$1'" % [$p.read])
result.add('^')
if (quoted and QSAFE_CHARS.contains(p.peek)) or
(not quoted and SAFE_CHARS.contains(p.peek)):
result.add(p.read)
else: result.add(c)
if quoted and p.read != '"':
@@ -1332,6 +1685,26 @@ macro genPropParsers(
params = params))
of vtText:
if pn == pnLang:
parseCase[1] = genAst(contents, typeName, p):
let valueType = params.getSingleValue("VALUE")
if valueType.isSome and valueType.get != $vtLanguageTag:
p.error("parameter 'VALUE' must have the value '" & $vtLanguageTag & "'")
contents.add(ac(typeName(value: p.readTextValue)))
elif pn == pnTz:
parseCase[1] = genAst(contents, typeName, p):
let valueType = params.getSingleValue("VALUE")
if valueType.isSome and
valueType.get notin [$vtText, $vtUri, $vtUtcOffset]:
p.error("parameter 'VALUE' must be one of 'text', 'uri', or 'utc-offset'")
contents.add(ac(typeName(
value:
if valueType.isSome and valueType.get in [$vtUri, $vtUtcOffset]:
p.readValue
else:
p.readTextValue)))
else:
parseCase[1] = genAst(contents, typeName, pt):
p.validateType(params, pt)
contents.add(ac(typeName(value: p.readTextValue)))
@@ -1355,7 +1728,9 @@ macro genPropParsers(
of vtUri:
parseCase[1] = genAst(typeName, contents, pt):
p.validateType(params, pt)
contents.add(ac(typeName(value: p.readValue)))
contents.add(ac(typeName(
mediaType: params.getSingleValue("MEDIATYPE"),
value: p.readValue)))
else:
raise newException(ValueError, "parse statements for for " & $pn &
@@ -1400,13 +1775,21 @@ macro genPropParsers(
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(';')))))
poBox: p.readComponentValueList,
ext: p.readComponentValueList(requiredPrefix = some(';')),
street: p.readComponentValueList(requiredPrefix = some(';')),
locality: p.readComponentValueList(requiredPrefix = some(';')),
region: p.readComponentValueList(requiredPrefix = some(';')),
postalCode: p.readComponentValueList(requiredPrefix = some(';')),
country: p.readComponentValueList(requiredPrefix = some(';')))))
block: # ORG
let parseCase = nnkOfBranch.newTree(ident("pnOrg"), newEmptyNode())
result.add(parseCase)
parseCase[1] = genAst(contents):
p.validateType(params, vtText)
contents.add(ac(VC4_Org(
value: p.readComponentValueList(seps = {';'}))))
block: # REV
let parseCase = nnkOfBranch.newTree(ident("pnRev"), newEmptyNode())
@@ -1450,6 +1833,7 @@ proc parseContentLines*(p: var VCardParser): seq[VC4_Property] =
p.expect("4.0")
sawVersion = true
else:
p.validateParamApplicability(name, params)
genPropParsers(fixedValueTypeProperties, group, name, params, result, p)
p.expect(CRLF)

View File

@@ -141,11 +141,287 @@ suite "vcard/vcard4":
let parsed = parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"EMAIL;PID=1.7:test@example.com"))
"EMAIL;PID=1.7:test@example.com",
"CLIENTPIDMAP:7;urn:uuid:device-7"))
check:
parsed.email.len == 1
parsed.email[0].pid == @[PidValue(propertyId: 1, sourceId: 7)]
test "spec: ADR supports structured list components":
check compiles(newVC4_Adr(street = @["123 Main St", "Unit 5"]))
when compiles(newVC4_Adr(street = @["123 Main St", "Unit 5"])):
let adr = newVC4_Adr(
street = @["123 Main St", "Unit 5"],
locality = @["Springfield"],
region = @["IL"],
postalCode = @["01111"],
country = @["USA"])
check serialize(adr) == "ADR:;;123 Main St,Unit 5;Springfield;IL;01111;USA"
let parsed = parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"ADR:;;123 Main St,Unit 5;Springfield;IL;01111;USA"))
check:
parsed.adr.len == 1
parsed.adr[0].street == @["123 Main St", "Unit 5"]
parsed.adr[0].locality == @["Springfield"]
parsed.adr[0].region == @["IL"]
parsed.adr[0].postalCode == @["01111"]
parsed.adr[0].country == @["USA"]
serialize(parsed.adr[0]) == "ADR:;;123 Main St,Unit 5;Springfield;IL;01111;USA"
test "spec: ADR escapes special characters in component values":
let adr = newVC4_Adr(
poBox = "Box, 7",
ext = "Suite; 9",
street = "123 Main St",
locality = "Montreal\nWest",
region = "QC\\CA",
postalCode = "H2Y 1C6",
country = "Canada")
check:
serialize(adr) ==
r"ADR:Box\, 7;Suite\; 9;123 Main St;Montreal\nWest;QC\\CA;H2Y 1C6;Canada"
test "spec: ADR constructors serialize GEO, TZ, and LABEL parameters":
let adr = newVC4_Adr(
street = "123 Main St",
geo = some("geo:46.772673,-71.282945"),
label = some("123 Main St., Suite 100"),
tz = some("America/Chicago"))
let serialized = serialize(adr)
check:
serialized.startsWith("ADR;")
serialized.contains("GEO=\"geo:46.772673,-71.282945\"")
serialized.contains("LABEL=\"123 Main St., Suite 100\"")
serialized.contains("TZ=America/Chicago")
serialized.endsWith(":;;123 Main St;;;;")
test "spec: ADR exposes GEO, TZ, and LABEL through typed accessors":
check compiles((block:
let parsed = parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"ADR;GEO=\"geo:46.772673,-71.282945\";" &
"LABEL=\"123 Main St., Suite 100\";TZ=America/Chicago:" &
";;123 Main St;Springfield;IL;01111;USA"))
parsed.adr[0].geo))
when compiles((block:
let parsed = parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"ADR;GEO=\"geo:46.772673,-71.282945\";" &
"LABEL=\"123 Main St., Suite 100\";TZ=America/Chicago:" &
";;123 Main St;Springfield;IL;01111;USA"))
parsed.adr[0].geo)):
let parsed = parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"ADR;GEO=\"geo:46.772673,-71.282945\";" &
"LABEL=\"123 Main St., Suite 100\";TZ=America/Chicago:" &
";;123 Main St;Springfield;IL;01111;USA"))
check:
parsed.adr.len == 1
parsed.adr[0].geo == some("geo:46.772673,-71.282945")
parsed.adr[0].label == some("123 Main St., Suite 100")
parsed.adr[0].tz == some("America/Chicago")
serialize(parsed.adr[0]) ==
"ADR;GEO=\"geo:46.772673,-71.282945\";" &
"LABEL=\"123 Main St., Suite 100\";TZ=America/Chicago:" &
";;123 Main St;Springfield;IL;01111;USA"
test "spec: ORG supports multiple organization units":
check compiles(newVC4_Org(
value = @["ABC, Inc.", "North American Division", "Marketing"]))
when compiles(newVC4_Org(
value = @["ABC, Inc.", "North American Division", "Marketing"])):
let org = newVC4_Org(
value = @["ABC, Inc.", "North American Division", "Marketing"])
check serialize(org) == "ORG:ABC\\, Inc.;North American Division;Marketing"
let parsed = parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"ORG:ABC\\, Inc.;North American Division;Marketing"))
check:
parsed.org.len == 1
parsed.org[0].value == @["ABC, Inc.", "North American Division", "Marketing"]
serialize(parsed.org[0]) == "ORG:ABC\\, Inc.;North American Division;Marketing"
test "spec: ORG round-trips structured input without escaping separators":
let parsed = parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"ORG:ABC\\, Inc.;North American Division;Marketing"))
check serialize(parsed.org[0]) == "ORG:ABC\\, Inc.;North American Division;Marketing"
test "spec: N and ORG support SORT-AS through the typed API":
check:
compiles(newVC4_N(
family = @["van der Harten"],
given = @["Rene"],
sortAs = @["Harten", "Rene"]))
compiles(newVC4_Org(
value = @["ABC, Inc.", "Marketing"],
sortAs = @["ABC Inc.", "Marketing"]))
when compiles(newVC4_N(
family = @["van der Harten"],
given = @["Rene"],
sortAs = @["Harten", "Rene"])) and
compiles(newVC4_Org(
value = @["ABC, Inc.", "Marketing"],
sortAs = @["ABC Inc.", "Marketing"])):
check:
serialize(newVC4_N(
family = @["van der Harten"],
given = @["Rene"],
sortAs = @["Harten", "Rene"])) ==
"N;SORT-AS=Harten,Rene:van der Harten;Rene;;;"
serialize(newVC4_Org(
value = @["ABC, Inc.", "Marketing"],
sortAs = @["ABC Inc.", "Marketing"])) ==
"ORG;SORT-AS=ABC Inc.,Marketing:ABC\\, Inc.;Marketing"
let parsed = parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:Rene van der Harten",
"N;SORT-AS=Harten,Rene:van der Harten;Rene;;;",
"ORG;SORT-AS=ABC Inc.,Marketing:ABC\\, Inc.;Marketing"))
check:
parsed.n.isSome
parsed.n.get.sortAs == @["Harten", "Rene"]
parsed.org.len == 1
parsed.org[0].sortAs == @["ABC Inc.", "Marketing"]
test "spec: BDAY and ANNIVERSARY support CALSCALE through the typed API":
check:
compiles(newVC4_Bday(value = "19960415", calscale = some("gregorian")))
compiles(newVC4_Anniversary(value = "20140612", calscale = some("gregorian")))
when compiles(newVC4_Bday(value = "19960415", calscale = some("gregorian"))) and
compiles(newVC4_Anniversary(value = "20140612", calscale = some("gregorian"))):
check:
serialize(newVC4_Bday(value = "19960415", calscale = some("gregorian"))) ==
"BDAY;CALSCALE=gregorian:19960415"
serialize(newVC4_Anniversary(
value = "20140612",
calscale = some("gregorian"))) ==
"ANNIVERSARY;CALSCALE=gregorian:20140612"
let parsed = parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"BDAY;CALSCALE=gregorian:19960415",
"ANNIVERSARY;CALSCALE=gregorian:20140612"))
check:
parsed.bday.isSome
parsed.bday.get.calscale == some("gregorian")
parsed.anniversary.isSome
parsed.anniversary.get.calscale == some("gregorian")
test "spec: typed BDAY and ANNIVERSARY constructors support reduced-precision values":
let bday = newVC4_Bday(month = some(12), day = some(24))
let anniversary = newVC4_Anniversary(year = some(2014), month = some(6))
check:
bday.value == "--1224"
bday.year.isNone
bday.month == some(12)
bday.day == some(24)
serialize(bday) == "BDAY:--1224"
anniversary.value == "201406"
anniversary.year == some(2014)
anniversary.month == some(6)
anniversary.day.isNone
serialize(anniversary) == "ANNIVERSARY:201406"
test "spec: unsupported standard parameters are rejected on known properties":
expect(VCardParsingError):
discard parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN;SORT-AS=Smith:John Smith"))
expect(VCardParsingError):
discard parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"EMAIL;LABEL=Inbox:test@example.com"))
expect(VCardParsingError):
discard parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"ORG;CALSCALE=gregorian:Example Corp"))
expect(VCardParsingError):
discard parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"CLIENTPIDMAP;PID=1.1:1;urn:uuid:client-map"))
test "spec: CALSCALE is rejected when BDAY or ANNIVERSARY use VALUE=text":
expect(VCardParsingError):
discard parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"BDAY;VALUE=text;CALSCALE=gregorian:circa 1800"))
expect(VCardParsingError):
discard parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"ANNIVERSARY;VALUE=text;CALSCALE=gregorian:childhood"))
test "spec: MEMBER requires KIND=group":
expect(VCardParsingError):
discard parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"MEMBER:urn:uuid:person-1"))
let parsed = parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"KIND:group",
"FN:The Doe Family",
"MEMBER:urn:uuid:person-1"))
check parsed.member.len == 1
test "spec: PID identifiers require positive values and matching CLIENTPIDMAP":
expect(VCardParsingError):
discard parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"EMAIL;PID=1.1:test@example.com"))
expect(VCardParsingError):
discard parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"EMAIL;PID=0.1:test@example.com",
"CLIENTPIDMAP:1;urn:uuid:device-1"))
expect(VCardParsingError):
discard parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"EMAIL;PID=1.0:test@example.com",
"CLIENTPIDMAP:0;urn:uuid:device-1"))
let parsed = parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"EMAIL;PID=1.1:test@example.com",
"CLIENTPIDMAP:1;urn:uuid:device-1"))
check:
parsed.email.len == 1
parsed.clientpidmap.len == 1
parsed.email[0].pid == @[PidValue(propertyId: 1, sourceId: 1)]
test "can parse properties with escaped characters":
check v4Ex.note.len == 1
let note = v4Ex.note[0]
@@ -171,6 +447,24 @@ suite "vcard/vcard4":
label.len == 1
label[0].values == @["^top\nsecond line"]
test "spec: RFC 6868 unknown escapes pass through in unquoted parameter values":
let parsed = parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN;X-TEST=alpha^xbeta:John Smith"))
let param = parsed.fn[0].params --> find(it.name == "X-TEST")
check:
param.isSome
param.get.values == @["alpha^xbeta"]
test "spec: RFC 6868 unknown escapes pass through in quoted parameter values":
let parsed = parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN;X-TEST=\"alpha^xbeta\":John Smith"))
let param = parsed.fn[0].params --> find(it.name == "X-TEST")
check:
param.isSome
param.get.values == @["alpha^xbeta"]
test "Data URIs are parsed correctly":
let expectedB64 = readFile("tests/allen.foster.jpg.uri")
@@ -200,6 +494,39 @@ suite "vcard/vcard4":
let src = newVC4_Source(value="https://carddav.example.test/john-smith.vcf")
check serialize(src) == "SOURCE:https://carddav.example.test/john-smith.vcf"
test "spec: LANG supports explicit VALUE=language-tag":
let parsed = parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"LANG;VALUE=language-tag;PREF=1:en-US"))
check:
parsed.lang.len == 1
parsed.lang[0].value == "en-US"
parsed.lang[0].valueType == some("language-tag")
serialize(parsed.lang[0]) == "LANG;VALUE=language-tag;PREF=1:en-US"
test "spec: TZ supports VALUE=utc-offset":
let parsed = parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"TZ;VALUE=utc-offset:-0500"))
check:
parsed.tz.len == 1
parsed.tz[0].value == "-0500"
parsed.tz[0].valueType == some("utc-offset")
serialize(parsed.tz[0]) == "TZ;VALUE=utc-offset:-0500"
test "spec: TZ supports VALUE=uri":
let parsed = parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"TZ;VALUE=uri:https://example.com/tz/America-Chicago"))
check:
parsed.tz.len == 1
parsed.tz[0].value == "https://example.com/tz/America-Chicago"
parsed.tz[0].valueType == some("uri")
serialize(parsed.tz[0]) == "TZ;VALUE=uri:https://example.com/tz/America-Chicago"
test "Single-text properties are parsed correctly":
# Covers KIND, XML, FN, NICKNAME, EMAIL, LANG, TZ, TITLE, ROLE, ORG, NOTE,
# PRODID, and VERSION
@@ -257,11 +584,27 @@ suite "vcard/vcard4":
v4Ex.gender.get.sex == some(VC4_Sex.Male)
v4Ex.gender.get.genderIdentity == some("male")
#[
test "CATEGORIES is parsed correctly":
test "REV is parsed correctly":
check:
v4Ex.rev.isSome
v4Ex.rev.get.value.year == 2022
v4Ex.rev.get.value.month == mFeb
v4Ex.rev.get.value.monthday == 26
v4Ex.rev.get.value.hour == 6
v4Ex.rev.get.value.minute == 8
v4Ex.rev.get.value.second == 28
test "CLIENTPIDMAP is parsed correctly":
]#
let parsed = parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"EMAIL;PID=1.1:test@example.com",
"CLIENTPIDMAP:1;urn:uuid:device-1"))
check:
parsed.clientpidmap.len == 1
parsed.clientpidmap[0].id == 1
parsed.clientpidmap[0].uri == "urn:uuid:device-1"
serialize(parsed.clientpidmap[0]) == "CLIENTPIDMAP:1;urn:uuid:device-1"
test "unknown properties are parsed correctly":

1
vcard-test-suite Submodule

Submodule vcard-test-suite added at d41f89179a