Compare commits
40 Commits
ce70e5ddd4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fc8b069af0 | |||
| 0e4198933a | |||
| 331502b6b8 | |||
| 8e17a96145 | |||
| d968486473 | |||
| d1318acbf9 | |||
| addb2a2d8d | |||
| ac25a4ec06 | |||
| 45e530a4ca | |||
| 15d2b7ecd0 | |||
| 04392ac203 | |||
| 45ec8efc8a | |||
| 2379196b0b | |||
| eb6d21c9bf | |||
| bbef5ed92d | |||
| b1cf3bb867 | |||
| 6012989432 | |||
| 5cdffd9126 | |||
| 4f050b9068 | |||
| 4bc76fa2f8 | |||
| 90e746abdd | |||
| 7e933dd30f | |||
| 99a36f71d0 | |||
| 8ea67163e7 | |||
| 35e191d82b | |||
| c2f194bdc2 | |||
| 4221b3af7c | |||
| d3d5f46096 | |||
| 6b1dceb2cd | |||
| 2ae016f426 | |||
| e3f0fdc1ab | |||
| cfac536d60 | |||
| 8f2a05cde6 | |||
| 6e6e06bdc4 | |||
| 201556ecbe | |||
| 35377f5a25 | |||
| 466e47fd36 | |||
| 3d2d40667d | |||
| c9de20f3a7 | |||
| ab2579bdb5 |
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "vcard-test-suite"]
|
||||||
|
path = vcard-test-suite
|
||||||
|
url = https://gitlab.com/pwithnall/vcard-test-suite.git
|
||||||
4
Makefile
4
Makefile
@@ -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
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ proc add*[T](vc: VCard, content: varargs[T]): void =
|
|||||||
if vc.parsedVersion == VCardV3: add(cast[VCard3](vc), content)
|
if vc.parsedVersion == VCardV3: add(cast[VCard3](vc), content)
|
||||||
else: add(cast[VCard4](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
|
# Read the preamble
|
||||||
discard p.readGroup
|
discard p.readGroup
|
||||||
p.expect("begin:vcard" & CRLF)
|
p.expect("begin:vcard" & CRLF)
|
||||||
@@ -55,22 +55,41 @@ 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
|
||||||
|
|
||||||
|
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 =
|
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
|
result.filename = filename
|
||||||
lexer.open(result, input)
|
lexer.open(result, input)
|
||||||
|
|
||||||
proc initVCardParser*(content: string, filename = "input"): VCardParser =
|
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)
|
initVCardParser(newStringStream(content), filename)
|
||||||
|
|
||||||
proc initVCardParserFromFile*(filepath: string): VCardParser =
|
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)
|
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)
|
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] =
|
proc parseVCards*(content: string, filename = "input", validate = true): seq[VCard] =
|
||||||
parseVCards(newStringStream(content), filename)
|
parseVCards(newStringStream(content), filename, validate)
|
||||||
|
|
||||||
proc parseVCardsFromFile*(filepath: string): seq[VCard] =
|
proc parseVCardsFromFile*(filepath: string, validate = true): seq[VCard] =
|
||||||
parseVCards(newFileStream(filepath, fmRead), filepath)
|
parseVCards(newFileStream(filepath, fmRead), filepath, validate)
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -136,7 +136,6 @@ const fixedValueTypeProperties = [
|
|||||||
(pnTitle, vtText),
|
(pnTitle, vtText),
|
||||||
(pnRole, vtText),
|
(pnRole, vtText),
|
||||||
(pnLogo, vtUri),
|
(pnLogo, vtUri),
|
||||||
(pnOrg, vtText),
|
|
||||||
(pnMember, vtUri),
|
(pnMember, vtUri),
|
||||||
(pnRelated, vtTextOrUri),
|
(pnRelated, vtTextOrUri),
|
||||||
(pnCategories, vtTextList),
|
(pnCategories, vtTextList),
|
||||||
@@ -172,13 +171,23 @@ const supportedParams: Table[string, HashSet[VC4_PropertyName]] = [
|
|||||||
pnTz, pnGeo, pnTitle, pnRole, pnLogo, pnOrg, pnRelated, pnCategories,
|
pnTz, pnGeo, pnTitle, pnRole, pnLogo, pnOrg, pnRelated, pnCategories,
|
||||||
pnNote, pnSound, pnUrl, pnKey, pnFburl, pnCaladrUri, pnCalUri ].toHashSet),
|
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
|
].toTable
|
||||||
|
|
||||||
const TIMESTAMP_FORMATS = [
|
const TIMESTAMP_FORMATS = [
|
||||||
"yyyyMMdd'T'hhmmssZZZ",
|
"yyyyMMdd'T'HHmmssZZZ",
|
||||||
"yyyyMMdd'T'hhmmssZZ",
|
"yyyyMMdd'T'HHmmssZZ",
|
||||||
"yyyyMMdd'T'hhmmssZ",
|
"yyyyMMdd'T'HHmmssZ",
|
||||||
"yyyyMMdd'T'hhmmss"
|
"yyyyMMdd'T'HHmmss"
|
||||||
]
|
]
|
||||||
|
|
||||||
const TEXT_CHARS = WSP + NON_ASCII + { '\x21'..'\x2B', '\x2D'..'\x7E' }
|
const TEXT_CHARS = WSP + NON_ASCII + { '\x21'..'\x2B', '\x2D'..'\x7E' }
|
||||||
@@ -334,14 +343,17 @@ type
|
|||||||
sex*: Option[VC4_Sex]
|
sex*: Option[VC4_Sex]
|
||||||
genderIdentity*: Option[string]
|
genderIdentity*: Option[string]
|
||||||
|
|
||||||
|
VC4_Org* = ref object of VC4_Property
|
||||||
|
value*: seq[string]
|
||||||
|
|
||||||
VC4_Adr* = ref object of VC4_Property
|
VC4_Adr* = ref object of VC4_Property
|
||||||
poBox*: string
|
poBox*: seq[string]
|
||||||
ext*: string
|
ext*: seq[string]
|
||||||
street*: string
|
street*: seq[string]
|
||||||
locality*: string
|
locality*: seq[string]
|
||||||
region*: string
|
region*: seq[string]
|
||||||
postalCode*: string
|
postalCode*: seq[string]
|
||||||
country*: string
|
country*: seq[string]
|
||||||
|
|
||||||
VC4_ClientPidMap* = ref object of VC4_Property
|
VC4_ClientPidMap* = ref object of VC4_Property
|
||||||
id*: int
|
id*: int
|
||||||
@@ -381,6 +393,92 @@ func flattenParameters(
|
|||||||
result = @[]
|
result = @[]
|
||||||
for k, v in paramTable.pairs: result.add((k, v))
|
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](
|
proc parseDateAndOrTime[T](
|
||||||
prop: var T,
|
prop: var T,
|
||||||
value: string
|
value: string
|
||||||
@@ -448,7 +546,7 @@ proc parseDateAndOrTime[T](
|
|||||||
|
|
||||||
proc parseTimestamp(value: string): DateTime =
|
proc parseTimestamp(value: string): DateTime =
|
||||||
for fmt in TIMESTAMP_FORMATS:
|
for fmt in TIMESTAMP_FORMATS:
|
||||||
try: return value.parse(fmt)
|
try: return value.parse(fmt, utc())
|
||||||
except: discard
|
except: discard
|
||||||
raise newException(VCardParsingError, "unable to parse timestamp value: " & value)
|
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) =
|
template validateType(p: VCardParser, params: seq[VC_Param], t: VC4_ValueType) =
|
||||||
p.validateRequiredParameters(params, [("VALUE", $t)])
|
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 =
|
func cmp[T: VC4_Property](x, y: T): int =
|
||||||
return cmp(x.pref, y.pref)
|
return cmp(x.pref, y.pref)
|
||||||
|
|
||||||
@@ -575,11 +696,13 @@ macro genDateTimeOrTextPropInitializers(
|
|||||||
let datetimeFuncDef = genAstOpt({kDirtyTemplate}, enumName, initFuncName, typeName):
|
let datetimeFuncDef = genAstOpt({kDirtyTemplate}, enumName, initFuncName, typeName):
|
||||||
func initFuncName*(
|
func initFuncName*(
|
||||||
value: DateTime,
|
value: DateTime,
|
||||||
|
calscale: Option[string] = none[string](),
|
||||||
altId: Option[string] = none[string](),
|
altId: Option[string] = none[string](),
|
||||||
group: Option[string] = none[string](),
|
group: Option[string] = none[string](),
|
||||||
params: seq[VC_Param] = @[]): typeName =
|
params: seq[VC_Param] = @[]): typeName =
|
||||||
return typeName(
|
return typeName(
|
||||||
params: flattenParameters(params,
|
params: flattenParameters(params,
|
||||||
|
("CALSCALE", if calscale.isSome: @[calscale.get] else: @[]),
|
||||||
("ALTID", if altId.isSome: @[altId.get] else: @[])),
|
("ALTID", if altId.isSome: @[altId.get] else: @[])),
|
||||||
group: group,
|
group: group,
|
||||||
value: value.format(TIMESTAMP_FORMATS[0]),
|
value: value.format(TIMESTAMP_FORMATS[0]),
|
||||||
@@ -596,12 +719,17 @@ macro genDateTimeOrTextPropInitializers(
|
|||||||
proc initFuncName*(
|
proc initFuncName*(
|
||||||
value: string,
|
value: string,
|
||||||
valueType: Option[string] = some($vtDateAndOrTime),
|
valueType: Option[string] = some($vtDateAndOrTime),
|
||||||
|
calscale: Option[string] = none[string](),
|
||||||
altId: Option[string] = none[string](),
|
altId: Option[string] = none[string](),
|
||||||
group: Option[string] = none[string](),
|
group: Option[string] = none[string](),
|
||||||
params: seq[VC_Param] = @[]): typeName =
|
params: seq[VC_Param] = @[]): typeName =
|
||||||
result = typeName(
|
result = typeName(
|
||||||
params: flattenParameters(params,
|
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,
|
group: group,
|
||||||
value: value,
|
value: value,
|
||||||
valueType: vtText)
|
valueType: vtText)
|
||||||
@@ -677,7 +805,8 @@ macro genTextOrUriPropInitializers(
|
|||||||
params: flattenParameters(params,
|
params: flattenParameters(params,
|
||||||
("ALTID", if altId.isSome: @[altId.get] else: @[])),
|
("ALTID", if altId.isSome: @[altId.get] else: @[])),
|
||||||
isUrl: isUrl,
|
isUrl: isUrl,
|
||||||
group: group)
|
group: group,
|
||||||
|
value: value)
|
||||||
|
|
||||||
addConditionalParams(prop, funcDef)
|
addConditionalParams(prop, funcDef)
|
||||||
result.add(funcDef)
|
result.add(funcDef)
|
||||||
@@ -698,7 +827,8 @@ macro genUriPropInitializers(
|
|||||||
params: seq[VC_Param] = @[]): typeName =
|
params: seq[VC_Param] = @[]): typeName =
|
||||||
return typeName(
|
return typeName(
|
||||||
params: flattenParameters(params,
|
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,
|
group: group,
|
||||||
mediaType: mediaType,
|
mediaType: mediaType,
|
||||||
value: value)
|
value: value)
|
||||||
@@ -721,18 +851,74 @@ genTextOrUriPropInitializers(fixedValueTypeProperties -->
|
|||||||
genUriPropInitializers(fixedValueTypeProperties -->
|
genUriPropInitializers(fixedValueTypeProperties -->
|
||||||
filter(it[1] == vtUri).map(it[0]))
|
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*(
|
func newVC4_N*(
|
||||||
family: seq[string] = @[],
|
family: seq[string] = @[],
|
||||||
given: seq[string] = @[],
|
given: seq[string] = @[],
|
||||||
additional: seq[string] = @[],
|
additional: seq[string] = @[],
|
||||||
prefixes: seq[string] = @[],
|
prefixes: seq[string] = @[],
|
||||||
suffixes: seq[string] = @[],
|
suffixes: seq[string] = @[],
|
||||||
|
sortAs: seq[string] = @[],
|
||||||
altId: Option[string] = none[string](),
|
altId: Option[string] = none[string](),
|
||||||
group: Option[string] = none[string](),
|
group: Option[string] = none[string](),
|
||||||
params: seq[VC_Param] = @[]): VC4_N =
|
params: seq[VC_Param] = @[]): VC4_N =
|
||||||
|
|
||||||
return assignFields(
|
return assignFields(
|
||||||
VC4_N(params: flattenParameters(params,
|
VC4_N(params: flattenParameters(params,
|
||||||
|
("SORT-AS", sortAs),
|
||||||
("ALTID", if altId.isSome: @[altId.get] else: @[]))),
|
("ALTID", if altId.isSome: @[altId.get] else: @[]))),
|
||||||
group, family, given, additional, prefixes, suffixes)
|
group, family, given, additional, prefixes, suffixes)
|
||||||
|
|
||||||
@@ -748,14 +934,60 @@ func newVC4_Gender*(
|
|||||||
("ALTID", if altId.isSome: @[altId.get] else: @[]))),
|
("ALTID", if altId.isSome: @[altId.get] else: @[]))),
|
||||||
sex, genderIdentity, group)
|
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*(
|
func newVC4_Adr*(
|
||||||
poBox = "",
|
poBox: seq[string] = @[],
|
||||||
ext = "",
|
ext: seq[string] = @[],
|
||||||
street = "",
|
street: seq[string] = @[],
|
||||||
locality = "",
|
locality: seq[string] = @[],
|
||||||
region = "",
|
region: seq[string] = @[],
|
||||||
postalCode = "",
|
postalCode: seq[string] = @[],
|
||||||
country = "",
|
country: seq[string] = @[],
|
||||||
altId: Option[string] = none[string](),
|
altId: Option[string] = none[string](),
|
||||||
geo: Option[string] = none[string](),
|
geo: Option[string] = none[string](),
|
||||||
group: Option[string] = none[string](),
|
group: Option[string] = none[string](),
|
||||||
@@ -782,6 +1014,44 @@ func newVC4_Adr*(
|
|||||||
("TZ", if tz.isSome: @[tz.get] else: @[]))),
|
("TZ", if tz.isSome: @[tz.get] else: @[]))),
|
||||||
poBox, ext, street, locality, region, postalCode, country, group)
|
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*(
|
func newVC4_ClientPidMap*(
|
||||||
id: int,
|
id: int,
|
||||||
uri: string,
|
uri: string,
|
||||||
@@ -842,7 +1112,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 =
|
||||||
@@ -911,7 +1181,7 @@ macro genPidAccessors(props: static[openarray[VC4_PropertyName]]): untyped =
|
|||||||
|
|
||||||
let pidFunc = genAstOpt({kDirtyTemplate}, typeName):
|
let pidFunc = genAstOpt({kDirtyTemplate}, typeName):
|
||||||
func pid*(prop: typeName): seq[PidValue] =
|
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)
|
if pidParam.isSome: return parsePidValues(pidParam.get)
|
||||||
else: return @[]
|
else: return @[]
|
||||||
result.add(pidFunc)
|
result.add(pidFunc)
|
||||||
@@ -941,6 +1211,32 @@ func altId*(p: VC4_Property): Option[string] =
|
|||||||
func valueType*(p: VC4_Property): Option[string] =
|
func valueType*(p: VC4_Property): Option[string] =
|
||||||
p.params.getSingleValue("VALUE")
|
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]] =
|
func allAlternatives*[T](vc4: VCard4): Table[string, seq[T]] =
|
||||||
result = initTable[string, seq[T]]()
|
result = initTable[string, seq[T]]()
|
||||||
|
|
||||||
@@ -968,6 +1264,99 @@ 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
|
||||||
|
|
||||||
|
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
|
# Setters
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@@ -1023,8 +1412,7 @@ macro genSerializers(
|
|||||||
of vtTextList:
|
of vtTextList:
|
||||||
let funcDef = genAstOpt({kDirtyTemplate}, enumName, typeName):
|
let funcDef = genAstOpt({kDirtyTemplate}, enumName, typeName):
|
||||||
func serialize*(p: typeName): string =
|
func serialize*(p: typeName): string =
|
||||||
result = p.nameWithGroup & serialize(p.params) &
|
result = p.nameWithGroup & serialize(p.params) & ":" &
|
||||||
serialize(p.params) & ":" &
|
|
||||||
(p.value --> map(serializeValue(it))).join(",")
|
(p.value --> map(serializeValue(it))).join(",")
|
||||||
result.add(funcDef)
|
result.add(funcDef)
|
||||||
|
|
||||||
@@ -1069,30 +1457,42 @@ func serializeValue(value: string): string =
|
|||||||
result = value.multiReplace(
|
result = value.multiReplace(
|
||||||
[(",", "\\,"), (";", "\\;"), ("\\", "\\\\"),("\n", "\\n")])
|
[(",", "\\,"), (";", "\\;"), ("\\", "\\\\"),("\n", "\\n")])
|
||||||
|
|
||||||
|
func serializeComponentList(values: seq[string]): string =
|
||||||
|
(values --> map(serializeValue(it))).join(",")
|
||||||
|
|
||||||
func serialize*(n: VC4_N): string =
|
func serialize*(n: VC4_N): string =
|
||||||
result = "N" & serialize(n.params) & ":" &
|
result = n.nameWithGroup & serialize(n.params) & ":" &
|
||||||
(n.family --> map(serializeValue(it))).join(",") & ";" &
|
(n.family --> map(serializeValue(it))).join(",") & ";" &
|
||||||
(n.given --> map(serializeValue(it))).join(",") & ";" &
|
(n.given --> map(serializeValue(it))).join(",") & ";" &
|
||||||
(n.additional --> map(serializeValue(it))).join(",") & ";" &
|
(n.additional --> map(serializeValue(it))).join(",") & ";" &
|
||||||
(n.prefixes --> map(serializeValue(it))).join(",") & ";" &
|
(n.prefixes --> map(serializeValue(it))).join(",") & ";" &
|
||||||
(n.suffixes --> 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 =
|
func serialize*(a: VC4_Adr): string =
|
||||||
result = "ADR" & serialize(a.params) & ":" &
|
result = a.nameWithGroup & serialize(a.params) & ":" &
|
||||||
a.poBox & ";" & a.ext & ";" & a.street & ";" & a.locality & ";" &
|
serializeComponentList(a.poBox) & ";" &
|
||||||
a.region & ";" & a.postalCode & ";" & a.country
|
serializeComponentList(a.ext) & ";" &
|
||||||
|
serializeComponentList(a.street) & ";" &
|
||||||
|
serializeComponentList(a.locality) & ";" &
|
||||||
|
serializeComponentList(a.region) & ";" &
|
||||||
|
serializeComponentList(a.postalCode) & ";" &
|
||||||
|
serializeComponentList(a.country)
|
||||||
|
|
||||||
func serialize*(g: VC4_Gender): string =
|
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.sex.isSome: result &= $g.sex.get
|
||||||
if g.genderIdentity.isSome: result &= ";" & g.genderIdentity.get
|
if g.genderIdentity.isSome: result &= ";" & g.genderIdentity.get
|
||||||
|
|
||||||
func serialize*(r: VC4_Rev): string =
|
func serialize*(r: VC4_Rev): string =
|
||||||
result = "REV" & serialize(r.params) &
|
result = r.nameWithGroup & serialize(r.params) &
|
||||||
":" & r.value.format(TIMESTAMP_FORMATS[0])
|
":" & r.value.format(TIMESTAMP_FORMATS[0])
|
||||||
|
|
||||||
func serialize*(c: VC4_ClientPidMap): string =
|
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)])
|
genSerializers(fixedValueTypeProperties.toSeq & @[(pnUnknown, vtText)])
|
||||||
genGenericSerializer(toSeq(VC4_PropertyName))
|
genGenericSerializer(toSeq(VC4_PropertyName))
|
||||||
@@ -1139,7 +1539,10 @@ proc readParamValue(p: var VCardParser): string =
|
|||||||
result.add('"')
|
result.add('"')
|
||||||
discard p.read
|
discard p.read
|
||||||
else:
|
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)
|
else: result.add(c)
|
||||||
|
|
||||||
if quoted and p.read != '"':
|
if quoted and p.read != '"':
|
||||||
@@ -1282,6 +1685,26 @@ macro genPropParsers(
|
|||||||
params = params))
|
params = params))
|
||||||
|
|
||||||
of vtText:
|
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):
|
parseCase[1] = genAst(contents, typeName, pt):
|
||||||
p.validateType(params, pt)
|
p.validateType(params, pt)
|
||||||
contents.add(ac(typeName(value: p.readTextValue)))
|
contents.add(ac(typeName(value: p.readTextValue)))
|
||||||
@@ -1305,7 +1728,9 @@ macro genPropParsers(
|
|||||||
of vtUri:
|
of vtUri:
|
||||||
parseCase[1] = genAst(typeName, contents, pt):
|
parseCase[1] = genAst(typeName, contents, pt):
|
||||||
p.validateType(params, pt)
|
p.validateType(params, pt)
|
||||||
contents.add(ac(typeName(value: p.readValue)))
|
contents.add(ac(typeName(
|
||||||
|
mediaType: params.getSingleValue("MEDIATYPE"),
|
||||||
|
value: p.readValue)))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise newException(ValueError, "parse statements for for " & $pn &
|
raise newException(ValueError, "parse statements for for " & $pn &
|
||||||
@@ -1350,13 +1775,21 @@ macro genPropParsers(
|
|||||||
parseCase[1] = genAst(contents):
|
parseCase[1] = genAst(contents):
|
||||||
p.validateType(params, vtText)
|
p.validateType(params, vtText)
|
||||||
contents.add(ac(VC4_Adr(
|
contents.add(ac(VC4_Adr(
|
||||||
poBox: p.readComponentValue,
|
poBox: p.readComponentValueList,
|
||||||
ext: p.readComponentValue(requiredPrefix = some(';')),
|
ext: p.readComponentValueList(requiredPrefix = some(';')),
|
||||||
street: p.readComponentValue(requiredPrefix = some(';')),
|
street: p.readComponentValueList(requiredPrefix = some(';')),
|
||||||
locality: p.readComponentValue(requiredPrefix = some(';')),
|
locality: p.readComponentValueList(requiredPrefix = some(';')),
|
||||||
region: p.readComponentValue(requiredPrefix = some(';')),
|
region: p.readComponentValueList(requiredPrefix = some(';')),
|
||||||
postalCode: p.readComponentValue(requiredPrefix = some(';')),
|
postalCode: p.readComponentValueList(requiredPrefix = some(';')),
|
||||||
country: p.readComponentValue(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
|
block: # REV
|
||||||
let parseCase = nnkOfBranch.newTree(ident("pnRev"), newEmptyNode())
|
let parseCase = nnkOfBranch.newTree(ident("pnRev"), newEmptyNode())
|
||||||
@@ -1382,6 +1815,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 +1826,21 @@ proc parseContentLines*(p: var VCardParser): seq[VC4_Property] =
|
|||||||
let params = p.readParams
|
let params = p.readParams
|
||||||
p.expect(":")
|
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)
|
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
1
tests/runner.nim
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import ./[tlexer, tvcard3, tvcard4]
|
||||||
@@ -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":
|
||||||
|
|||||||
@@ -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:")
|
||||||
|
|||||||
@@ -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,351 @@ 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",
|
||||||
|
"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":
|
test "can parse properties with escaped characters":
|
||||||
check v4Ex.note.len == 1
|
check v4Ex.note.len == 1
|
||||||
@@ -96,6 +447,24 @@ suite "vcard/vcard4":
|
|||||||
label.len == 1
|
label.len == 1
|
||||||
label[0].values == @["^top\nsecond line"]
|
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":
|
test "Data URIs are parsed correctly":
|
||||||
let expectedB64 = readFile("tests/allen.foster.jpg.uri")
|
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")
|
let src = newVC4_Source(value="https://carddav.example.test/john-smith.vcf")
|
||||||
check serialize(src) == "SOURCE: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":
|
test "Single-text properties are parsed correctly":
|
||||||
# Covers KIND, XML, FN, NICKNAME, EMAIL, LANG, TZ, TITLE, ROLE, ORG, NOTE,
|
# Covers KIND, XML, FN, NICKNAME, EMAIL, LANG, TZ, TITLE, ROLE, ORG, NOTE,
|
||||||
# PRODID, and VERSION
|
# PRODID, and VERSION
|
||||||
@@ -182,11 +584,27 @@ suite "vcard/vcard4":
|
|||||||
v4Ex.gender.get.sex == some(VC4_Sex.Male)
|
v4Ex.gender.get.sex == some(VC4_Sex.Male)
|
||||||
v4Ex.gender.get.genderIdentity == some("male")
|
v4Ex.gender.get.genderIdentity == some("male")
|
||||||
|
|
||||||
#[
|
|
||||||
test "CATEGORIES is parsed correctly":
|
|
||||||
test "REV 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":
|
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":
|
test "unknown properties are parsed correctly":
|
||||||
|
|
||||||
@@ -197,6 +615,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 +623,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 +643,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:
|
||||||
|
|||||||
1
vcard-test-suite
Submodule
1
vcard-test-suite
Submodule
Submodule vcard-test-suite added at d41f89179a
Reference in New Issue
Block a user