Compare commits

...

11 Commits

Author SHA1 Message Date
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
10 changed files with 624 additions and 66 deletions

View File

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

View File

@@ -55,6 +55,14 @@ proc readVCard*(p: var VCardParser): VCard =
if result.parsedVersion == VCardV3: if result.parsedVersion == VCardV3:
while (p.skip(CRLF, true)): discard while (p.skip(CRLF, true)): discard
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 = proc initVCardParser*(input: Stream, filename = "input"): VCardParser =
result.filename = filename result.filename = filename
lexer.open(result, input) lexer.open(result, input)

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. ## name. If there is not a valid group the read position is left unchanged.
p.setBookmark p.setBookmark
let validChars = ALPHA_NUM + {'-'}
var ch = p.read var ch = p.read
while ALPHA_NUM.contains(ch): ch = p.read while validChars.contains(ch): ch = p.read
if (ch == '.'): if (ch == '.'):
result = some(readSinceBookmark(p)[0..^2]) 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 # at least three characters in the buffer
else: else:
return vcl.buffer[wrappedIdx(vcl.pos + 1)] == '\n' and 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 = proc read*(vcl: var VCardLexer, peek = false): char =
## Read one byte off of the input stream. By default this will advance the ## 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") 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": # "fillBuffer correctness":
block: block:
var l: VCardLexer var l: VCardLexer

View File

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

View File

@@ -842,7 +842,7 @@ macro genPropAccessors(
of vpcAtLeastOne, vpcAny: of vpcAtLeastOne, vpcAny:
let funcDef = genAstOpt({kDirtyTemplate}, funcName, typeName): let funcDef = genAstOpt({kDirtyTemplate}, funcName, typeName):
func funcName*(vc4: VCard4): seq[typeName] = func funcName*(vc4: VCard4): seq[typeName] =
result = findAll[typeName](vc4.content) result = findAll[typeName, VC4_Property](vc4.content)
result.add(funcDef) result.add(funcDef)
macro genNameAccessors(propNames: static[seq[VC4_PropertyName]]): untyped = macro genNameAccessors(propNames: static[seq[VC4_PropertyName]]): untyped =
@@ -968,6 +968,56 @@ genPidAccessors(supportedParams["PID"].toSeq())
genPrefAccessors(supportedParams["PREF"].toSeq()) genPrefAccessors(supportedParams["PREF"].toSeq())
genTypeAccessors(supportedParams["TYPE"].toSeq()) genTypeAccessors(supportedParams["TYPE"].toSeq())
func countCardinalityInstances(vc4: VCard4, propName: string): int =
var altIds = initHashSet[string]()
for p in vc4.content:
if p.name != propName:
continue
if p.altId.isSome:
altIds.incl(p.altId.get)
else:
inc result
result += altIds.len
func validate*(vc4: VCard4): void =
## Validate property cardinality for a vCard 4.0 card.
for pn in VC4_PropertyName:
if pn == pnVersion:
continue
if not propertyCardMap.contains(pn):
continue
let rawCount = vc4.content.countIt(it.name == $pn)
let cardinalityCount =
case propertyCardMap[pn]
of vpcExactlyOne, vpcAtMostOne:
vc4.countCardinalityInstances($pn)
of vpcAtLeastOne, vpcAny:
rawCount
case propertyCardMap[pn]
of vpcExactlyOne:
if cardinalityCount != 1:
raise newException(ValueError,
"VCard should have exactly one $# property, but $# were found" %
[$pn, $cardinalityCount])
of vpcAtLeastOne:
if cardinalityCount < 1:
raise newException(ValueError,
"VCard should have at least one $# property, but $# were found" %
[$pn, $cardinalityCount])
of vpcAtMostOne:
if cardinalityCount > 1:
raise newException(ValueError,
("VCard should have at most one $# property, but $# " &
"distinct properties were found") % [$pn, $cardinalityCount])
of vpcAny:
discard
# Setters # Setters
# ============================================================================= # =============================================================================
@@ -1382,6 +1432,7 @@ macro genPropParsers(
proc parseContentLines*(p: var VCardParser): seq[VC4_Property] = proc parseContentLines*(p: var VCardParser): seq[VC4_Property] =
result = @[] result = @[]
var sawVersion = false
while true: while true:
let group = p.readGroup let group = p.readGroup
@@ -1392,10 +1443,20 @@ proc parseContentLines*(p: var VCardParser): seq[VC4_Property] =
let params = p.readParams let params = p.readParams
p.expect(":") p.expect(":")
genPropParsers(fixedValueTypeProperties, group, name, params, result, p) if name == $pnVersion:
if sawVersion:
p.error("VCard should have exactly one VERSION property, but 2 were found")
p.validateType(params, vtText)
p.expect("4.0")
sawVersion = true
else:
genPropParsers(fixedValueTypeProperties, group, name, params, result, p)
p.expect(CRLF) p.expect(CRLF)
if not sawVersion:
p.error("VCard should have exactly one VERSION property, but 0 were found")
# Private Function Unit Tests # 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 unittest
import ./vcard/private/lexer import vcard/private/lexer
suite "vcard/private/lexer": suite "vcard/private/lexer":
test "private lexer tests": 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
import ./vcard/vcard3 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": suite "vcard/vcard3":
@@ -43,6 +57,7 @@ suite "vcard/vcard3":
"BEGIN:vCard\r\n" & "BEGIN:vCard\r\n" &
"VERSION:3.0\r\n" & "VERSION:3.0\r\n" &
"FN:Frank Dawson\r\n" & "FN:Frank Dawson\r\n" &
"N:Dawson;Frank;;;\r\n" &
"ORG:Lotus Development Corporation\r\n" & "ORG:Lotus Development Corporation\r\n" &
"ADR;TYPE=WORK,POSTAL,PARCEL:;;6544 Battleford Drive\r\n" & "ADR;TYPE=WORK,POSTAL,PARCEL:;;6544 Battleford Drive\r\n" &
" ;Raleigh;NC;27613-3502;U.S.A.\r\n" & " ;Raleigh;NC;27613-3502;U.S.A.\r\n" &
@@ -57,6 +72,7 @@ suite "vcard/vcard3":
"BEGIN:vCard\r\n" & "BEGIN:vCard\r\n" &
"VERSION:3.0\r\n" & "VERSION:3.0\r\n" &
"FN:Tim Howes\r\n" & "FN:Tim Howes\r\n" &
"N:Howes;Tim;;;\r\n" &
"ORG:Netscape Communications Corp.\r\n" & "ORG:Netscape Communications Corp.\r\n" &
"ADR;TYPE=WORK:;;501 E. Middlefield Rd.;Mountain View;\r\n" & "ADR;TYPE=WORK:;;501 E. Middlefield Rd.;Mountain View;\r\n" &
" CA; 94043;U.S.A.\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]).fn.value == "Frank Dawson"
cast[VCard3](vcards[0]).email.len == 2 cast[VCard3](vcards[0]).email.len == 2
(cast[VCard3](vcards[0]).email --> find(it.emailType.contains("PREF"))).isSome (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 zero_functional
import ./vcard import vcard
import ./vcard/vcard4 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": suite "vcard/vcard4":
@@ -68,8 +76,75 @@ suite "vcard/vcard4":
types = @["work", "internet"], types = @["work", "internet"],
params = @[("PREF", @["1"]), ("X-ATTACHMENT-LIMIT", @["25MB"])]) params = @[("PREF", @["1"]), ("X-ATTACHMENT-LIMIT", @["25MB"])])
check serialize(email) == let serialized = serialize(email)
"EMAIL;X-ATTACHMENT-LIMIT=25MB;TYPE=work,internet;PREF=1:john.smith@testco.test" 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"))
check:
parsed.email.len == 1
parsed.email[0].pid == @[PidValue(propertyId: 1, sourceId: 7)]
test "can parse properties with escaped characters": test "can parse properties with escaped characters":
check v4Ex.note.len == 1 check v4Ex.note.len == 1
@@ -197,6 +272,7 @@ suite "vcard/vcard4":
madeUpProp.value == "Sample value for my made-up prop." madeUpProp.value == "Sample value for my made-up prop."
let cardWithAltBdayStr = testVCardTemplate % [( let cardWithAltBdayStr = testVCardTemplate % [(
"FN:Simon Perreault\r\n" &
"BDAY;VALUE=text;ALTID=1:20th century\r\n" & "BDAY;VALUE=text;ALTID=1:20th century\r\n" &
"BDAY;VALUE=date-and-or-time;ALTID=1:19650321\r\n" "BDAY;VALUE=date-and-or-time;ALTID=1:19650321\r\n"
)] )]
@@ -204,6 +280,14 @@ suite "vcard/vcard4":
test "single-cardinality properties allow multiples with ALTID": test "single-cardinality properties allow multiples with ALTID":
check parseVCards(cardWithAltBdayStr).len == 1 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]) let hasAltBdays = cast[VCard4](parseVCards(cardWithAltBdayStr)[0])
test "properties with cardinality 1 and altids return the first found by default": test "properties with cardinality 1 and altids return the first found by default":
@@ -216,6 +300,7 @@ suite "vcard/vcard4":
check: check:
hasAltBdays.content.len == 3 hasAltBdays.content.len == 3
hasAltBdays.bday.isSome hasAltBdays.bday.isSome
hasAltBdays.content.countIt(it of VC4_Version) == 0
let allBdays = allAlternatives[VC4_Bday](hasAltBdays) let allBdays = allAlternatives[VC4_Bday](hasAltBdays)
check: check: