Compare commits

40 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
2ae016f426 Failing tests for vCard v4 fix sets. 2026-03-28 19:54:17 -05:00
e3f0fdc1ab Refactor vCard validation and canonicalize VERSION handling
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
2026-03-28 19:41:09 -05:00
cfac536d60 Fix vCard 3 PROFILE and AGENT handling
Parse PROFILE as the concrete VC3_Profile type instead of a bare
VC3_Property, and validate PROFILE parameters under the correct
content-type name.

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

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

AI-Assisted: yes
AI-Tool: OpenAI Codex / gpt-5.4 xhigh
2026-03-28 13:33:20 -05:00
8f2a05cde6 Fix vCard 3 REV and KEY value handling
Bring the remaining vCard 3 REV and KEY behavior into line with RFC
2426.

For REV, serialize VALUE=date using the date form instead of incorrectly
emitting VALUE=date-time with a timestamp payload.

For KEY, stop defaulting constructed values to VALUE=uri. The vCard 3
specification defines KEY as binary by default and allows it to be reset
to text, but not to uri. Tighten both construction and parsing
accordingly: reject VALUE=uri for KEY, enforce the relationship between
VALUE=binary and ENCODING=b, and reject VALUE=text when ENCODING=b is
present.

Update the regression coverage to reflect the spec boundary: PHOTO,
LOGO, and SOUND may round-trip as uris; KEY may contain text that looks
like a URI; KEY does not allow VALUE=uri; and vCard 3 KEY parameters
still require name=value syntax.

AI-Assisted: yes
AI-Tool: OpenAI Codex / gpt-5.4 xhigh
2026-03-28 10:46:41 -05:00
6e6e06bdc4 Fix quoted MIME-DIR parameter parsing
Correct the vCard 3 parameter parser so quoted parameter values are
consumed according to the MIME-DIR grammar instead of failing
immediately on the opening double quote.

The fix explicitly advances past the opening quote, reads the quoted
qsafe-char sequence, and strips the surrounding quotes from the returned
parameter value. Unquoted parameter handling is unchanged.

Add private parser coverage for quoted parameter values and quoted
values containing commas, plus a public regression test showing that
quoted LANGUAGE and TYPE parameters are accepted by the vCard 3 parser.

AI-Assisted: yes
AI-Tool: OpenAI Codex / gpt-5.4 xhigh
2026-03-28 10:32:58 -05:00
201556ecbe Fix vCard 3 text escaping and decoding
Implement RFC 2426 text escaping consistently across vCard 3
serialization and parsing.

On serialization, escape backslashes, newlines, semicolons, and commas
for simple text properties, structured text components, and list-valued
text properties so generated FN, N, ADR, ORG, CATEGORIES, and related
properties are spec-compliant on the wire.

On parsing, decode escaped text for the properties that were previously
read as raw values: FN, NICKNAME, LABEL, MAILER, TITLE, ROLE, PRODID,
and SORT-STRING. This preserves existing structured-text parsing for N,
ADR, NOTE, ORG, and CATEGORIES while fixing the direct raw-value
mismatch identified in the review.

Add regression coverage for both directions: parsing escaped text values
and serializing escaped simple, structured, and list text values.

AI-Assisted: yes
AI-Tool: OpenAI Codex / gpt-5.4 xhigh
2026-03-28 10:27:37 -05:00
35377f5a25 Fix vCard 3 inline binary round-tripping
Decode ENCODING=b payloads for PHOTO, LOGO, SOUND, and KEY when parsing
so serializing a parsed card does not base64-encode already encoded wire
data a second time. Add regression coverage for both inline binary and
VALUE=uri round-trips.

AI-Assisted: yes
AI-Tool: OpenAI Codex /gpt-5.4 xhigh
2026-03-28 10:13:52 -05:00
466e47fd36 Fix vCard folding and group parsing rules
Accept horizontal-tab continuations when unfolding content lines and
allow hyphens in group names, matching the MIME-DIR and vCard grammar.
Also add focused private tests covering both cases.

AI-Assisted: yes
AI-Tool: OpenAI Codex / gpt-5.4 xhigh
2026-03-28 10:04:53 -05:00
3d2d40667d Add a unified test runner for better test output. 2026-03-28 10:03:43 -05:00
c9de20f3a7 WIP: Test cases covering findings surfaced by ChatGPT. 2026-03-28 09:58:12 -05:00
ab2579bdb5 Modifications to compile under Nim 2.x 2026-03-28 09:55:17 -05:00
12 changed files with 1421 additions and 121 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

@@ -16,8 +16,8 @@ docs: doc/vcard/vcard.html
.PHONY: test
test:
#@for t in $(TESTS); do $$t; done
nimble --warning:BareExcept:off test
nimble c tests/runner.nim
./tests/runner
.PHONY: install
install: test

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,22 +55,41 @@ 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()
else:
cast[VCard4](result).validate()
except ValueError as exc:
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

@@ -163,8 +163,9 @@ proc readGroup*(p: var VCardParser): Option[string] =
## name. If there is not a valid group the read position is left unchanged.
p.setBookmark
let validChars = ALPHA_NUM + {'-'}
var ch = p.read
while ALPHA_NUM.contains(ch): ch = p.read
while validChars.contains(ch): ch = p.read
if (ch == '.'):
result = some(readSinceBookmark(p)[0..^2])

View File

@@ -202,7 +202,7 @@ proc isLineWrap(vcl: var VCardLexer, allowRefill = true): bool =
# at least three characters in the buffer
else:
return vcl.buffer[wrappedIdx(vcl.pos + 1)] == '\n' and
vcl.buffer[wrappedIdx(vcl.pos + 2)] == ' '
vcl.buffer[wrappedIdx(vcl.pos + 2)] in {' ', '\t'}
proc read*(vcl: var VCardLexer, peek = false): char =
## Read one byte off of the input stream. By default this will advance the
@@ -392,6 +392,13 @@ proc runVcardLexerPrivateTests*() =
assert l.readExpected("line wrap\r\nline 2")
# "handles wrapped lines with tabs":
block:
var l: VCardLexer
l.open(newStringStream("line\r\n\twrap\r\nline 2"), 3)
assert l.readExpected("linewrap\r\nline 2")
# "fillBuffer correctness":
block:
var l: VCardLexer

View File

@@ -520,7 +520,11 @@ func newVC3_Agent*(
isInline = true,
group = none[string]()): VC3_Agent =
return VC3_Agent(name: $pnAgent, isInline: isInline, group: group)
return VC3_Agent(
name: $pnAgent,
value: value,
isInline: isInline,
group: group)
func newVC3_Org*(
value: seq[string],
@@ -609,11 +613,22 @@ func newVC3_Class*(value: string, group = none[string]()): VC3_Class =
func newVC3_Key*(
value: string,
valueType = some("uri"),
valueType = none[string](),
keyType = none[string](),
isInline = false,
group = none[string]()): VC3_Key =
if valueType.isSome:
if valueType.get == $vtUri:
raise newException(ValueError,
"KEY content does not support the '" & $vtUri & "' value type")
elif valueType.get == $vtBinary and not isInline:
raise newException(ValueError,
"KEY content with VALUE=binary must also specify ENCODING=b")
elif valueType.get == $vtText and isInline:
raise newException(ValueError,
"KEY content with ENCODING=b cannot use VALUE=text")
return assignFields(
VC3_Key(name: $pnKey, binaryType: keyType),
value, valueType, keyType, isInline, group)
@@ -661,7 +676,7 @@ macro genPropertyAccessors(
of vpcAtMostOne:
let funcDef = genAstOpt({kDirtyTemplate}, funcName, typeName):
func funcName*(vc3: VCard3): Option[typeName] =
result = findFirst[typeName](vc3.content)
result = findFirst[typeName, VC3_Property](vc3.content)
funcDef[6].insert(0, newCommentStmtNode(
"Return the single " & $pn & " property (if present)."))
result.add(funcDef)
@@ -670,7 +685,7 @@ macro genPropertyAccessors(
of vpcExactlyOne:
let funcDef = genAstOpt({kDirtyTemplate}, funcName, pn, typeName):
func funcName*(vc3: VCard3): typeName =
let props = findAll[typeName](vc3.content)
let props = findAll[typeName, VC3_Property](vc3.content)
if props.len != 1:
raise newException(ValueError,
"VCard should have exactly one $# property, but $# were found" %
@@ -683,7 +698,7 @@ macro genPropertyAccessors(
of vpcAtLeastOne, vpcAny:
let funcDef = genAstOpt({kDirtyTemplate}, funcName, typeName):
func funcName*(vc3: VCard3): seq[typeName] =
result = findAll[typeName](vc3.content)
result = findAll[typeName, VC3_Property](vc3.content)
funcDef[6].insert(0, newCommentStmtNode(
"Return all instances of the " & $pn & " property."))
result.add(funcDef)
@@ -692,18 +707,39 @@ genPropertyAccessors(propertyCardMap.pairs.toSeq -->
filter(not [pnVersion].contains(it[0])))
func version*(vc3: VCard3): VC3_Version =
## Return the VERSION property.
let found = findFirst[VC3_Version](vc3.content)
if found.isSome: return found.get
else: return VC3_Version(
propertyId: vc3.content.len + 1,
group: none[string](),
name: "VERSION",
value: "3.0")
## Return the canonical VERSION property for a vCard 3.0 card.
result = newVC3_Version()
func xTypes*(vc3: VCard3): seq[VC3_XType] = findAll[VC3_XType](vc3.content)
func xTypes*(vc3: VCard3): seq[VC3_XType] = findAll[VC3_XType, VC3_Property](vc3.content)
## Return all extended properties (starting with `x-`).
func validate*(vc3: VCard3): void =
## Validate property cardinality for a vCard 3.0 card.
for pn in VC3_PropertyName:
if pn == pnVersion:
continue
let count = vc3.content.countIt(it.name == $pn)
case propertyCardMap[pn]
of vpcExactlyOne:
if count != 1:
raise newException(ValueError,
"VCard should have exactly one $# property, but $# were found" %
[$pn, $count])
of vpcAtLeastOne:
if count < 1:
raise newException(ValueError,
"VCard should have at least one $# property, but $# were found" %
[$pn, $count])
of vpcAtMostOne:
if count > 1:
raise newException(ValueError,
"VCard should have at most one $# property, but $# were found" %
[$pn, $count])
of vpcAny:
discard
# Setters
# =============================================================================
@@ -752,17 +788,28 @@ func serialize(s: VC3_Source): string =
result &= serialize(s.xParams)
result &= ":" & s.value
func serializeTextValue(value: string): string =
value.multiReplace([
("\\", "\\\\"),
("\n", "\\n"),
(";", "\\;"),
(",", "\\,")
])
func serializeTextValues(values: seq[string], sep: string): string =
(values --> map(serializeTextValue(it))).join(sep)
func serialize(n: VC3_N): string =
result = n.nameWithGroup
if n.isPText: result &= ";VALUE=ptext"
if n.language.isSome: result &= ";LANGUAGE=" & n.language.get
result &= serialize(n.xParams)
result &= ":" &
n.family.join(",") & ";" &
n.given.join(",") & ";" &
n.additional.join(",") & ";" &
n.prefixes.join(",") & ";" &
n.suffixes.join(",")
serializeTextValues(n.family, ",") & ";" &
serializeTextValues(n.given, ",") & ";" &
serializeTextValues(n.additional, ",") & ";" &
serializeTextValues(n.prefixes, ",") & ";" &
serializeTextValues(n.suffixes, ",")
func serialize(b: VC3_Bday): string =
result = b.nameWithGroup
@@ -778,13 +825,13 @@ func serialize(a: VC3_Adr): string =
if a.language.isSome: result &= ";LANGUAGE=" & a.language.get
result &= serialize(a.xParams)
result &= ":" &
a.poBox & ";" &
a.extendedAdr & ";" &
a.streetAdr & ";" &
a.locality & ";" &
a.region & ";" &
a.postalCode & ";" &
a.country
serializeTextValue(a.poBox) & ";" &
serializeTextValue(a.extendedAdr) & ";" &
serializeTextValue(a.streetAdr) & ";" &
serializeTextValue(a.locality) & ";" &
serializeTextValue(a.region) & ";" &
serializeTextValue(a.postalCode) & ";" &
serializeTextValue(a.country)
proc serialize(t: VC3_Tel): string =
result = t.nameWithGroup
@@ -801,7 +848,7 @@ func serialize(s: VC3_SimpleTextProperty): string =
if s.isPText: result &= ";VALUE=ptext"
if s.language.isSome: result &= ";LANGUAGE=" & s.language.get
result &= serialize(s.xParams)
result &= ":" & s.value
result &= ":" & serializeTextValue(s.value)
proc serialize(b: VC3_BinaryProperty): string =
result = b.nameWithGroup
@@ -830,21 +877,21 @@ proc serialize(o: VC3_Org): string =
if o.isPText: result &= ";VALUE=ptext"
if o.language.isSome: result &= ";LANGUAGE=" & o.language.get
result &= serialize(o.xParams)
result &= ":" & o.value.join(",")
result &= ":" & serializeTextValues(o.value, ",")
proc serialize(c: VC3_Categories): string =
result = c.nameWithGroup
if c.isPText: result &= ";VALUE=ptext"
if c.language.isSome: result &= ";LANGUAGE=" & c.language.get
result &= serialize(c.xParams)
result &= ":" & c.value.join(",")
result &= ":" & serializeTextValues(c.value, ",")
proc serialize(r: VC3_Rev): string =
result = r.nameWithGroup
if r.valueType.isSome and r.valueType.get == "date-time":
result &= ";VALUE=date-time:" & r.value.format(DATETIME_FMT)
elif r.valueType.isSome and r.valueType.get == "date":
result &= ";VALUE=date-time:" & r.value.format(DATETIME_FMT)
result &= ";VALUE=date:" & r.value.format(DATE_FMT)
else:
result &= r.value.format(DATETIME_FMT)
@@ -891,11 +938,12 @@ proc readParamValue(p: var VCardParser): string =
## Read a single parameter value at the current read position or error.
p.setBookmark
if p.peek == '"':
discard p.read
while QSAFE_CHARS.contains(p.peek): discard p.read
if p.read != '"':
p.error("quoted parameter value expected to end with a " &
"double quote (\")")
result = p.readSinceBookmark[0 ..< ^1]
result = p.readSinceBookmark[1 ..< ^1]
else:
while SAFE_CHARS.contains(p.peek): discard p.read
result = p.readSinceBookmark
@@ -963,8 +1011,47 @@ proc readTextValueList(
result = @[p.readTextValue]
while seps.contains(p.peek): result.add(p.readTextValue(ignorePrefix = seps))
proc decodeTextValue(p: var VCardParser, value: string): string =
result = newStringOfCap(value.len)
var idx = 0
while idx < value.len:
let c = value[idx]
if c != '\\':
result.add(c)
inc idx
continue
if idx + 1 >= value.len:
p.error("invalid character escape: '\\'")
case value[idx + 1]
of '\\', ';', ',':
result.add(value[idx + 1])
of 'n', 'N':
result.add('\n')
else:
p.error("invalid character escape: '\\$1'" % [$value[idx + 1]])
inc idx, 2
proc readBinaryValue(
p: var VCardParser,
isInline: bool,
propName: string
): string =
let value = p.readValue
if not isInline:
return value
try:
return base64.decode(value)
except ValueError:
p.error("invalid base64 value for the " & propName & " content type")
proc parseContentLines*(p: var VCardParser): seq[VC3_Property] =
result = @[]
var sawVersion = false
macro assignCommon(assign: untyped): untyped =
result = assign
@@ -1006,8 +1093,8 @@ proc parseContentLines*(p: var VCardParser): seq[VC3_Property] =
if p.readValue.toUpper != "VCARD":
p.error("the value of the PROFILE content type must be \"$1\"" %
["vcard"])
p.validateNoParameters(params, "NAME")
result.add(VC3_Property(group: group, name: name))
p.validateNoParameters(params, "PROFILE")
result.add(newVC3_Profile(group))
of $pnSource:
p.validateRequiredParameters(params,
@@ -1021,7 +1108,8 @@ proc parseContentLines*(p: var VCardParser): seq[VC3_Property] =
xParams = params.getXParams))
of $pnFn:
result.add(assignCommon(newVC3_Fn(value = p.readValue)))
result.add(assignCommon(newVC3_Fn(
value = p.decodeTextValue(p.readValue))))
of $pnN:
result.add(assignCommon(newVC3_N(
@@ -1032,15 +1120,17 @@ proc parseContentLines*(p: var VCardParser): seq[VC3_Property] =
suffixes = p.readTextValueList(ifPrefix = some(';')))))
of $pnNickname:
result.add(assignCommon(newVC3_Nickname(value = p.readValue)))
result.add(assignCommon(newVC3_Nickname(
value = p.decodeTextValue(p.readValue))))
of $pnPhoto:
let isInline = params.existsWithValue("ENCODING", "B")
result.add(newVC3_Photo(
group = group,
value = p.readValue,
value = p.readBinaryValue(isInline, "PHOTO"),
valueType = params.getSingleValue("VALUE"),
binaryType = params.getSingleValue("TYPE"),
isInline = params.existsWithValue("ENCODING", "B")))
isInline = isInline))
of $pnBday:
let valueType = params.getSingleValue("VALUE")
@@ -1078,7 +1168,7 @@ proc parseContentLines*(p: var VCardParser): seq[VC3_Property] =
of $pnLabel:
result.add(assignCommon(newVC3_Label(
value = p.readValue,
value = p.decodeTextValue(p.readValue),
adrType = params.getMultipleValues("TYPE"))))
of $pnTel:
@@ -1094,7 +1184,8 @@ proc parseContentLines*(p: var VCardParser): seq[VC3_Property] =
emailType = params.getMultipleValues("TYPE")))
of $pnMailer:
result.add(assignCommon(newVC3_Mailer(value = p.readValue)))
result.add(assignCommon(newVC3_Mailer(
value = p.decodeTextValue(p.readValue))))
of $pnTz:
result.add(newVC3_Tz(
@@ -1115,18 +1206,21 @@ proc parseContentLines*(p: var VCardParser): seq[VC3_Property] =
"content type but received '" & rawValue & "'")
of $pnTitle:
result.add(assignCommon(newVC3_Title(value = p.readValue)))
result.add(assignCommon(newVC3_Title(
value = p.decodeTextValue(p.readValue))))
of $pnRole:
result.add(assignCommon(newVC3_Role(value = p.readValue)))
result.add(assignCommon(newVC3_Role(
value = p.decodeTextValue(p.readValue))))
of $pnLogo:
let isInline = params.existsWithValue("ENCODING", "B")
result.add(newVC3_Logo(
group = group,
value = p.readValue,
value = p.readBinaryValue(isInline, "LOGO"),
valueType = params.getSingleValue("VALUE"),
binaryType = params.getSingleValue("TYPE"),
isInline = params.existsWithValue("ENCODING", "B")))
isInline = isInline))
of $pnAgent:
let valueParam = params.getSingleValue("VALUE")
@@ -1152,7 +1246,8 @@ proc parseContentLines*(p: var VCardParser): seq[VC3_Property] =
result.add(assignCommon(newVC3_Note(value = p.readTextValue)))
of $pnProdid:
result.add(assignCommon(newVC3_Prodid(value = p.readValue)))
result.add(assignCommon(newVC3_Prodid(
value = p.decodeTextValue(p.readValue))))
of $pnRev:
let valueType = params.getSingleValue("VALUE")
@@ -1179,15 +1274,17 @@ proc parseContentLines*(p: var VCardParser): seq[VC3_Property] =
))
of $pnSortString:
result.add(assignCommon(newVC3_SortString(value = p.readValue)))
result.add(assignCommon(newVC3_SortString(
value = p.decodeTextValue(p.readValue))))
of $pnSound:
let isInline = params.existsWithValue("ENCODING", "B")
result.add(newVC3_Sound(
group = group,
value = p.readValue,
value = p.readBinaryValue(isInline, "SOUND"),
valueType = params.getSingleValue("VALUE"),
binaryType = params.getSingleValue("TYPE"),
isInline = params.existsWithValue("ENCODING", "B")))
isInline = isInline))
of $pnUid:
result.add(newVC3_UID(group = group, value = p.readValue))
@@ -1196,20 +1293,32 @@ proc parseContentLines*(p: var VCardParser): seq[VC3_Property] =
result.add(newVC3_URL(group = group, value = p.readValue))
of $pnVersion:
if sawVersion:
p.error("VCard should have exactly one VERSION property, but 2 were found")
p.expect("3.0")
p.validateNoParameters(params, "VERSION")
result.add(newVC3_Version(group = group))
sawVersion = true
of $pnClass:
result.add(newVC3_Class(group = group, value = p.readValue))
of $pnKey:
let valueType = params.getSingleValue("VALUE")
let isInline = params.existsWithValue("ENCODING", "B")
if valueType.isSome:
if valueType.get == $vtUri:
p.error("invalid VALUE for KEY content. " &
"Expected '" & $vtText & "' or '" & $vtBinary & "'")
elif valueType.get == $vtBinary and not isInline:
p.error("KEY content with VALUE=binary must also specify ENCODING=b")
elif valueType.get == $vtText and isInline:
p.error("KEY content with ENCODING=b cannot use VALUE=text")
result.add(newVC3_Key(
group = group,
value = p.readValue,
valueType = params.getSingleValue("VALUE"),
value = p.readBinaryValue(isInline, "KEY"),
valueType = valueType,
keyType = params.getSingleValue("TYPE"),
isInline = params.existsWithValue("ENCODING", "B")))
isInline = isInline))
else:
if not name.startsWith("X-"):
@@ -1227,6 +1336,9 @@ proc parseContentLines*(p: var VCardParser): seq[VC3_Property] =
p.expect("\r\n")
if not sawVersion:
p.error("VCard should have exactly one VERSION property, but 0 were found")
#[
Simplified Parsing Diagram
@@ -1272,6 +1384,13 @@ proc runVCard3PrivateTests*() =
assert g.isSome
assert g.get == "mygroup"
# "readGroup with hyphen":
block:
var p = initParser("item-1.BEGIN:VCARD")
let g = p.readGroup
assert g.isSome
assert g.get == "item-1"
# "readGroup without group":
block:
var p = initParser("BEGIN:VCARD")
@@ -1326,6 +1445,24 @@ proc runVCard3PrivateTests*() =
assert p.read == '='
assert p.readParamValue == "Fun&Games%"
# "readParamValue quoted":
block:
var p = initParser("FN;LANGUAGE=\"en\":John Smith")
assert p.readName == "FN"
assert p.read == ';'
assert p.readName == "LANGUAGE"
assert p.read == '='
assert p.readParamValue == "en"
# "readParamValue quoted with comma":
block:
var p = initParser("LABEL;TYPE=\"HOME,POSTAL\":123 Main St.")
assert p.readName == "LABEL"
assert p.read == ';'
assert p.readName == "TYPE"
assert p.read == '='
assert p.readParamValue == "HOME,POSTAL"
# "readParams":
block:
var p = initParser("TEL;TYPE=WORK;TYPE=Fun&Games%,Extra:+15551234567")
@@ -1340,6 +1477,15 @@ proc runVCard3PrivateTests*() =
assert params[1].values[0] == "Fun&Games%"
assert params[1].values[1] == "Extra"
# "readParams quoted":
block:
var p = initParser("FN;LANGUAGE=\"en\":John Smith")
assert p.readName == "FN"
let params = p.readParams
assert params.len == 1
assert params[0].name == "LANGUAGE"
assert params[0].values == @["en"]
# "readValue":
block:
var p = initParser("TEL;TYPE=WORK:+15551234567\r\nFN:John Smith\r\n")

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,
@@ -842,7 +1112,7 @@ macro genPropAccessors(
of vpcAtLeastOne, vpcAny:
let funcDef = genAstOpt({kDirtyTemplate}, funcName, typeName):
func funcName*(vc4: VCard4): seq[typeName] =
result = findAll[typeName](vc4.content)
result = findAll[typeName, VC4_Property](vc4.content)
result.add(funcDef)
macro genNameAccessors(propNames: static[seq[VC4_PropertyName]]): untyped =
@@ -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]]()
@@ -968,6 +1264,99 @@ 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
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
# =============================================================================
@@ -1023,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)
@@ -1069,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))
@@ -1139,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 != '"':
@@ -1282,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)))
@@ -1305,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 &
@@ -1350,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())
@@ -1382,6 +1815,7 @@ macro genPropParsers(
proc parseContentLines*(p: var VCardParser): seq[VC4_Property] =
result = @[]
var sawVersion = false
while true:
let group = p.readGroup
@@ -1392,10 +1826,21 @@ proc parseContentLines*(p: var VCardParser): seq[VC4_Property] =
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:
p.validateParamApplicability(name, params)
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
# ============================================================================

1
tests/runner.nim Normal file
View File

@@ -0,0 +1 @@
import ./[tlexer, tvcard3, tvcard4]

View File

@@ -1,5 +1,5 @@
import unittest
import ./vcard/private/lexer
import vcard/private/lexer
suite "vcard/private/lexer":
test "private lexer tests":

View File

@@ -1,7 +1,21 @@
import options, unittest, zero_functional
import std/[options, sequtils, strutils, times, unittest]
import zero_functional
import ./vcard
import ./vcard/vcard3
import vcard
import vcard/vcard3
template vcard3Doc(lines: varargs[string]): string =
"BEGIN:VCARD\r\n" &
lines.join("\r\n") &
"\r\nEND:VCARD\r\n"
proc parseSingleVCard3(content: string): VCard3 =
cast[VCard3](parseVCards(content)[0])
proc newMinimalVCard3(): VCard3 =
result = VCard3(parsedVersion: VCardV3)
result.add(newVC3_Fn("John Smith"))
result.add(newVC3_N(family = @["Smith"], given = @["John"]))
suite "vcard/vcard3":
@@ -43,6 +57,7 @@ suite "vcard/vcard3":
"BEGIN:vCard\r\n" &
"VERSION:3.0\r\n" &
"FN:Frank Dawson\r\n" &
"N:Dawson;Frank;;;\r\n" &
"ORG:Lotus Development Corporation\r\n" &
"ADR;TYPE=WORK,POSTAL,PARCEL:;;6544 Battleford Drive\r\n" &
" ;Raleigh;NC;27613-3502;U.S.A.\r\n" &
@@ -57,6 +72,7 @@ suite "vcard/vcard3":
"BEGIN:vCard\r\n" &
"VERSION:3.0\r\n" &
"FN:Tim Howes\r\n" &
"N:Howes;Tim;;;\r\n" &
"ORG:Netscape Communications Corp.\r\n" &
"ADR;TYPE=WORK:;;501 E. Middlefield Rd.;Mountain View;\r\n" &
" CA; 94043;U.S.A.\r\n" &
@@ -71,3 +87,236 @@ suite "vcard/vcard3":
cast[VCard3](vcards[0]).fn.value == "Frank Dawson"
cast[VCard3](vcards[0]).email.len == 2
(cast[VCard3](vcards[0]).email --> find(it.emailType.contains("PREF"))).isSome
test "spec: parser rejects cards missing VERSION":
expect(VCardParsingError):
discard parseVCards(vcard3Doc(
"FN:John Smith",
"N:Smith;John;;;"))
test "spec: parser rejects cards missing FN":
expect(VCardParsingError):
discard parseVCards(vcard3Doc(
"VERSION:3.0",
"N:Smith;John;;;"))
test "spec: parser rejects cards missing N":
expect(VCardParsingError):
discard parseVCards(vcard3Doc(
"VERSION:3.0",
"FN:John Smith"))
test "spec: parser rejects duplicate single-cardinality vCard 3 properties":
expect(VCardParsingError):
discard parseVCards(vcard3Doc(
"VERSION:3.0",
"FN:John Smith",
"N:Smith;John;;;",
"BDAY:2000-01-01",
"BDAY:2000-01-02"))
test "spec: VERSION is not stored as vCard 3 content":
let parsed = parseSingleVCard3(vcard3Doc(
"VERSION:3.0",
"FN:John Smith",
"N:Smith;John;;;"))
check:
parsed.version.value == "3.0"
parsed.content.countIt(it of VC3_Version) == 0
test "spec: simple text values decode RFC 2426 escapes when parsing":
let parsed = parseSingleVCard3(vcard3Doc(
"VERSION:3.0",
r"FN:Jane\, Smith\; Esq.\\Office\nSecond line",
"N:Smith;Jane;;;"))
check parsed.fn.value == "Jane, Smith; Esq.\\Office\nSecond line"
test "spec: affected text properties decode RFC 2426 escapes when parsing":
let parsed = parseSingleVCard3(vcard3Doc(
"VERSION:3.0",
"FN:John Smith",
"N:Smith;John;;;",
r"NICKNAME:Johnny\, Jr.\nTwo",
r"LABEL:123 Main St.\nSuite 100\; Mail Stop",
r"MAILER:Mailer\\Pro",
r"TITLE:Lead\; Engineer",
r"ROLE:Ops\, Support",
r"PRODID:-//Example\\Corp//EN",
r"SORT-STRING:Smith\, John"))
check:
parsed.nickname.isSome
parsed.nickname.get.value == "Johnny, Jr.\nTwo"
parsed.label.len == 1
parsed.label[0].value == "123 Main St.\nSuite 100; Mail Stop"
parsed.mailer.len == 1
parsed.mailer[0].value == "Mailer\\Pro"
parsed.title.len == 1
parsed.title[0].value == "Lead; Engineer"
parsed.role.len == 1
parsed.role[0].value == "Ops, Support"
parsed.prodid.isSome
parsed.prodid.get.value == "-//Example\\Corp//EN"
parsed.sortstring.isSome
parsed.sortstring.get.value == "Smith, John"
test "spec: simple text values escape special characters when serializing":
let vc = newMinimalVCard3()
vc.set(newVC3_Fn("Jane, Smith; Esq.\\Office\nSecond line"))
check ($vc).contains(r"FN:Jane\, Smith\; Esq.\\Office\nSecond line")
test "spec: structured text values escape special characters when serializing":
let vc = VCard3(parsedVersion: VCardV3)
vc.add(newVC3_Fn("John Smith"))
vc.add(newVC3_N(
family = @["Smith, Sr."],
given = @["John;Jack"],
additional = @["Back\\Slash"],
prefixes = @["Dr.\nProf."],
suffixes = @["III"]))
check ($vc).contains(r"N:Smith\, Sr.;John\;Jack;Back\\Slash;Dr.\nProf.;III")
test "spec: list text values escape special characters when serializing":
let vc = newMinimalVCard3()
vc.add(newVC3_Categories(@["alpha,beta", "semi;colon", "slash\\value"]))
check ($vc).contains(r"CATEGORIES:alpha\,beta,semi\;colon,slash\\value")
test "spec: inline binary values round-trip without double encoding":
let payload = "aGVsbG8="
let serialized = $parseSingleVCard3(vcard3Doc(
"VERSION:3.0",
"FN:John Smith",
"N:Smith;John;;;",
"PHOTO;ENCODING=b;TYPE=JPEG:" & payload,
"LOGO;ENCODING=b;TYPE=GIF:" & payload,
"SOUND;ENCODING=b;TYPE=WAVE:" & payload,
"KEY;ENCODING=b;TYPE=PGP:" & payload))
check:
serialized.contains("PHOTO;ENCODING=b;TYPE=JPEG:" & payload)
serialized.contains("LOGO;ENCODING=b;TYPE=GIF:" & payload)
serialized.contains("SOUND;ENCODING=b;TYPE=WAVE:" & payload)
serialized.contains("KEY;ENCODING=b;TYPE=PGP:" & payload)
test "spec: PHOTO, LOGO, and SOUND may round-trip as uris":
let serialized = $parseSingleVCard3(vcard3Doc(
"VERSION:3.0",
"FN:John Smith",
"N:Smith;John;;;",
"PHOTO;VALUE=uri:http://example.test/photo.jpg",
"LOGO;VALUE=uri:http://example.test/logo.gif",
"SOUND;VALUE=uri:http://example.test/sound.wav"))
check:
serialized.contains("PHOTO;VALUE=uri:http://example.test/photo.jpg")
serialized.contains("LOGO;VALUE=uri:http://example.test/logo.gif")
serialized.contains("SOUND;VALUE=uri:http://example.test/sound.wav")
test "spec: KEY does not allow uri values":
expect(VCardParsingError):
discard parseVCards(vcard3Doc(
"VERSION:3.0",
"FN:John Smith",
"N:Smith;John;;;",
"KEY;VALUE=uri:http://example.test/key.asc"))
test "spec: KEY may contain text values that look like uris":
let serialized = $parseSingleVCard3(vcard3Doc(
"VERSION:3.0",
"FN:John Smith",
"N:Smith;John;;;",
"KEY;TYPE=PGP:http://example.test/key.pgp"))
check serialized.contains("KEY;TYPE=PGP:http://example.test/key.pgp")
test "spec: KEY parameters must use name=value syntax in vCard 3":
expect(VCardParsingError):
discard parseVCards(vcard3Doc(
"VERSION:3.0",
"FN:John Smith",
"N:Smith;John;;;",
"KEY;PGP:http://example.test/key.pgp"))
test "spec: quoted parameter values are accepted":
let parsed = parseSingleVCard3(vcard3Doc(
"VERSION:3.0",
"FN;LANGUAGE=\"en\":John Smith",
"LABEL;TYPE=\"HOME,POSTAL\":123 Main St.",
"N:Smith;John;;;"))
check:
parsed.fn.language == some("en")
parsed.label.len == 1
parsed.label[0].adrType == @["HOME,POSTAL"]
test "spec: PROFILE is exposed as the standard property type":
let parsed = parseSingleVCard3(vcard3Doc(
"VERSION:3.0",
"PROFILE:VCARD",
"FN:John Smith",
"N:Smith;John;;;"))
check:
parsed.profile.isSome
($parsed).contains("PROFILE:VCARD")
test "spec: AGENT uri values survive parse and serialize":
let serialized = $parseSingleVCard3(vcard3Doc(
"VERSION:3.0",
"FN:John Smith",
"N:Smith;John;;;",
"AGENT;VALUE=uri:mailto:assistant@example.com"))
check serialized.contains("AGENT;VALUE=uri:mailto:assistant@example.com")
test "spec: constructed AGENT values serialize with their content":
let vc = newMinimalVCard3()
vc.add(newVC3_Agent("mailto:assistant@example.com", isInline = false))
check ($vc).contains("AGENT;VALUE=uri:mailto:assistant@example.com")
test "spec: folded lines may continue with horizontal tab":
let parsed = parseSingleVCard3(
"BEGIN:VCARD\r\n" &
"VERSION:3.0\r\n" &
"FN:John Smith\r\n" &
"N:Smith;John;;;\r\n" &
"NOTE:one two \r\n" &
"\tthree four\r\n" &
"END:VCARD\r\n")
check:
parsed.note.len == 1
parsed.note[0].value == "one two three four"
test "spec: group names may contain hyphens":
let parsed = parseSingleVCard3(vcard3Doc(
"VERSION:3.0",
"FN:John Smith",
"N:Smith;John;;;",
"item-1.EMAIL:test@example.com"))
check:
parsed.email.len == 1
parsed.email[0].group == some("item-1")
test "spec: REV with VALUE=date serializes as a date":
let vc = newMinimalVCard3()
vc.add(newVC3_Rev(
value = parse("2000-01-02", "yyyy-MM-dd"),
valueType = some("date")))
check ($vc).contains("REV;VALUE=date:2000-01-02")
test "spec: KEY defaults to text rather than uri":
let vc = newMinimalVCard3()
vc.add(newVC3_Key("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC"))
check:
($vc).contains("KEY:ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC")
not ($vc).contains("KEY;VALUE=uri:")

View File

@@ -1,8 +1,16 @@
import std/[options, strutils, tables, unittest]
import std/[options, sequtils, strutils, tables, times, unittest]
import zero_functional
import ./vcard
import ./vcard/vcard4
import vcard
import vcard/vcard4
template vcard4Doc(lines: varargs[string]): string =
"BEGIN:VCARD\r\n" &
lines.join("\r\n") &
"\r\nEND:VCARD\r\n"
proc parseSingleVCard4(content: string): VCard4 =
cast[VCard4](parseVCards(content)[0])
suite "vcard/vcard4":
@@ -68,8 +76,351 @@ suite "vcard/vcard4":
types = @["work", "internet"],
params = @[("PREF", @["1"]), ("X-ATTACHMENT-LIMIT", @["25MB"])])
check serialize(email) ==
"EMAIL;X-ATTACHMENT-LIMIT=25MB;TYPE=work,internet;PREF=1:john.smith@testco.test"
let serialized = serialize(email)
check:
serialized.startsWith("EMAIL;")
serialized.endsWith(":john.smith@testco.test")
serialized.contains(";PREF=1")
serialized.contains(";TYPE=work,internet")
serialized.contains(";X-ATTACHMENT-LIMIT=25MB")
serialized.count(';') == 3
test "spec: text-or-uri constructors serialize non-empty values":
check:
serialize(newVC4_Tel(value = "tel:+1-555-555-5555")) ==
"TEL:tel:+1-555-555-5555"
serialize(newVC4_Related(value = "urn:uuid:person-1")) ==
"RELATED:urn:uuid:person-1"
serialize(newVC4_Uid(value = "urn:uuid:card-1")) ==
"UID:urn:uuid:card-1"
serialize(newVC4_Key(value = "https://example.com/keys/john.asc")) ==
"KEY:https://example.com/keys/john.asc"
test "spec: text-list properties serialize parameters exactly once":
let nickname = newVC4_Nickname(value = @["Doc"], types = @["work"])
check serialize(nickname) == "NICKNAME;TYPE=work:Doc"
test "spec: handwritten serializers preserve group prefixes":
let rev = newVC4_Rev(value = now(), group = some("item1"))
check:
serialize(newVC4_N(family = @["Doe"], group = some("item1"))) ==
"item1.N:Doe;;;;"
serialize(newVC4_Adr(street = "Main", group = some("item1"))) ==
"item1.ADR:;;Main;;;;"
serialize(newVC4_Gender(sex = some(VC4_Sex.Male), group = some("item1"))) ==
"item1.GENDER:M"
serialize(newVC4_ClientPidMap(
id = 1,
uri = "urn:uuid:client-map",
group = some("item1"))) ==
"item1.CLIENTPIDMAP:1;urn:uuid:client-map"
serialize(rev).startsWith("item1.REV:")
test "spec: text-valued BDAY and ANNIVERSARY serialize with VALUE=text":
check:
serialize(newVC4_Bday(value = "circa 1800", valueType = some("text"))) ==
"BDAY;VALUE=text:circa 1800"
serialize(newVC4_Anniversary(value = "childhood", valueType = some("text"))) ==
"ANNIVERSARY;VALUE=text:childhood"
test "spec: URI properties round-trip MEDIATYPE":
let photo = newVC4_Photo(
value = "https://example.com/photo.jpg",
mediaType = some("image/jpeg"))
check serialize(photo) == "PHOTO;MEDIATYPE=image/jpeg:https://example.com/photo.jpg"
let parsed = parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"PHOTO;MEDIATYPE=image/jpeg:https://example.com/photo.jpg"))
check:
parsed.photo.len == 1
parsed.photo[0].mediaType == some("image/jpeg")
test "spec: typed PID accessors expose parsed PID values":
let parsed = parseSingleVCard4(vcard4Doc(
"VERSION:4.0",
"FN:John Smith",
"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
@@ -96,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")
@@ -125,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
@@ -182,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":
@@ -197,6 +615,7 @@ suite "vcard/vcard4":
madeUpProp.value == "Sample value for my made-up prop."
let cardWithAltBdayStr = testVCardTemplate % [(
"FN:Simon Perreault\r\n" &
"BDAY;VALUE=text;ALTID=1:20th century\r\n" &
"BDAY;VALUE=date-and-or-time;ALTID=1:19650321\r\n"
)]
@@ -204,6 +623,14 @@ suite "vcard/vcard4":
test "single-cardinality properties allow multiples with ALTID":
check parseVCards(cardWithAltBdayStr).len == 1
test "single-cardinality properties reject multiples without ALTID":
expect(VCardParsingError):
discard parseVCards(testVCardTemplate % [(
"FN:Simon Perreault\r\n" &
"BDAY;VALUE=text:20th century\r\n" &
"BDAY;VALUE=date-and-or-time:19650321\r\n"
)])
let hasAltBdays = cast[VCard4](parseVCards(cardWithAltBdayStr)[0])
test "properties with cardinality 1 and altids return the first found by default":
@@ -216,6 +643,7 @@ suite "vcard/vcard4":
check:
hasAltBdays.content.len == 3
hasAltBdays.bday.isSome
hasAltBdays.content.countIt(it of VC4_Version) == 0
let allBdays = allAlternatives[VC4_Bday](hasAltBdays)
check:

1
vcard-test-suite Submodule

Submodule vcard-test-suite added at d41f89179a