Parse PROFILE as the concrete VC3_Profile type instead of a bare VC3_Property, and validate PROFILE parameters under the correct content-type name. Preserve AGENT values in newVC3_Agent so both parsed and constructed AGENT properties serialize with their actual URI or inline content. Expand regression coverage to verify PROFILE is exposed and serialized as the standard property, parsed AGENT URI values round-trip correctly, and constructed AGENT values retain their content. AI-Assisted: yes AI-Tool: OpenAI Codex / gpt-5.4 xhigh
1532 lines
46 KiB
Nim
1532 lines
46 KiB
Nim
# vCard 3.0 implementation
|
|
# © 2022 Jonathan Bernard
|
|
|
|
## This module implements a high-performance vCard parser for vCard version
|
|
## 3.0 (defined in RFCs 2425_ and 2426_).
|
|
##
|
|
## .. _rfc2425: https://tools.ietf.org/html/rfc2425
|
|
## .. _rfc2426: https://tools.ietf.org/html/rfc2426
|
|
|
|
import std/[base64, genasts, macros, options, sequtils, streams, strutils,
|
|
tables, times, unicode]
|
|
|
|
import zero_functional
|
|
|
|
import ./common
|
|
import ./private/[internals, lexer]
|
|
|
|
# Internal enumerations used to capture the value types and properties defined
|
|
# by the spec and constants used.
|
|
type
|
|
VC3_ValueTypes = enum
|
|
vtUri = "uri",
|
|
vtText = "text",
|
|
vtPText = "ptext",
|
|
vtDate = "date",
|
|
vtTime = "time",
|
|
vtDateTime = "date-time",
|
|
vtInteger = "integer",
|
|
vtBoolean = "boolean",
|
|
vtFloat = "float",
|
|
vtBinary = "binary",
|
|
vtVCard = "vcard"
|
|
vtPhoneNumber = "phone-number"
|
|
vtUtcOffset = "utc-offset"
|
|
|
|
VC3_PropertyName = enum
|
|
pnName = "NAME"
|
|
pnProfile = "PROFILE"
|
|
pnSource = "SOURCE"
|
|
pnFn = "FN"
|
|
pnN = "N"
|
|
pnNickname = "NICKNAME"
|
|
pnPhoto = "PHOTO"
|
|
pnBday = "BDAY"
|
|
pnAdr = "ADR"
|
|
pnLabel = "LABEL"
|
|
pnTel = "TEL"
|
|
pnEmail = "EMAIL"
|
|
pnMailer = "MAILER"
|
|
pnTz = "TZ"
|
|
pnGeo = "GEO"
|
|
pnTitle = "TITLE"
|
|
pnRole = "ROLE"
|
|
pnLogo = "LOGO"
|
|
pnAgent = "AGENT"
|
|
pnOrg = "ORG"
|
|
pnCategories = "CATEGORIES"
|
|
pnNote = "NOTE"
|
|
pnProdid = "PRODID"
|
|
pnRev = "REV"
|
|
pnSortString = "SORT-STRING"
|
|
pnSound = "SOUND"
|
|
pnUid = "UID"
|
|
pnUrl = "URL"
|
|
pnVersion = "VERSION"
|
|
pnClass = "CLASS"
|
|
pnKey = "KEY"
|
|
|
|
const propertyCardMap: Table[VC3_PropertyName, VC_PropCardinality] = [
|
|
(pnName, vpcAtMostOne),
|
|
(pnProfile, vpcAtMostOne),
|
|
(pnSource, vpcAtMostOne),
|
|
(pnFn, vpcExactlyOne),
|
|
(pnN, vpcExactlyOne),
|
|
(pnNickname, vpcAtMostOne),
|
|
(pnPhoto, vpcAny),
|
|
(pnBday, vpcAtMostOne),
|
|
(pnAdr, vpcAny),
|
|
(pnLabel, vpcAny),
|
|
(pnTel, vpcAny),
|
|
(pnEmail, vpcAny),
|
|
(pnMailer, vpcAny),
|
|
(pnTz, vpcAny),
|
|
(pnGeo, vpcAny),
|
|
(pnTitle, vpcAny),
|
|
(pnRole, vpcAny),
|
|
(pnLogo, vpcAny),
|
|
(pnAgent, vpcAny),
|
|
(pnOrg, vpcAny),
|
|
(pnCategories, vpcAtMostOne),
|
|
(pnNote, vpcAny),
|
|
(pnProdid, vpcAtMostOne),
|
|
(pnRev, vpcAtMostOne),
|
|
(pnSortString, vpcAtMostOne),
|
|
(pnSound, vpcAny),
|
|
(pnUid, vpcAtMostOne),
|
|
(pnUrl, vpcAny),
|
|
(pnVersion, vpcExactlyOne),
|
|
(pnClass, vpcAny),
|
|
(pnKey, vpcAny)
|
|
].toTable
|
|
|
|
const DATE_FMT = "yyyy-MM-dd"
|
|
const DATETIME_FMT = "yyyy-MM-dd'T'HH:mm:sszz"
|
|
|
|
|
|
## Externally Exposed Types
|
|
## ========================
|
|
##
|
|
## The following are the object types for the vCard 3.0 implementation
|
|
## interface.
|
|
type
|
|
VC3_Property* = ref object of RootObj
|
|
## Abstract base class for all vCard 3.0 property objects.
|
|
propertyId: int
|
|
group*: Option[string]
|
|
name*: string
|
|
|
|
VC3_SimpleTextProperty* = ref object of VC3_Property
|
|
## Abstract base class for all vCard 3.0 properties that only support the
|
|
## text value type (`VALUE=text` or `VALUE=ptext`).
|
|
value*: string
|
|
isPText*: bool ## true if VALUE=ptext, false by default
|
|
language*: Option[string]
|
|
xParams*: seq[VC_XParam]
|
|
|
|
VC3_BinaryProperty* = ref object of VC3_Property
|
|
## Abstract base class for all vCard 3.0 properties that support the binary
|
|
## or uri value types (`ENCODING=b` or `VALUE=uri`).
|
|
valueType*: Option[string] ## \
|
|
## binary / uri. Stored separately from ENCODING (captured in the
|
|
## isInline field) because the VALUE parameter is not set by default, but
|
|
## is allowed to be set.
|
|
value*: string ## \
|
|
## either a URI or bit sequence, both stored as string
|
|
binaryType*: Option[string] ## \
|
|
## if using ENCODING=b, there may also be a TYPE parameter specifying the
|
|
## MIME type of the binary-encoded object, which is stored here.
|
|
isInline*: bool ## \
|
|
## true if ENCODING=b, false by default
|
|
|
|
VC3_Name* = ref object of VC3_Property
|
|
## Concrete class representing vCard 3.0 NAME properties.
|
|
value*: string
|
|
|
|
VC3_Profile* = ref object of VC3_Property
|
|
## Concrete class representing vCard 3.0 PROFILE properties.
|
|
|
|
VC3_Source* = ref object of VC3_Property
|
|
## Concrete class representing vCard 3.0 SOURCE properties.
|
|
valueType*: Option[string] ## If set, this must be set to "uri"
|
|
value*: string ## The URI value.
|
|
context*: Option[string]
|
|
xParams*: seq[VC_XParam]
|
|
|
|
VC3_Fn* = ref object of VC3_SimpleTextProperty
|
|
## Concrete class representing vCard 3.0 FN properties.
|
|
|
|
VC3_N* = ref object of VC3_Property
|
|
## Concrete class representing vCard 3.0 N properties.
|
|
family*: seq[string] ## Surname / family name
|
|
given*: seq[string] ## First name / given name
|
|
additional*: seq[string] ## Additional / middle names
|
|
prefixes*: seq[string] ## e.g. Mr., Dr., etc.
|
|
suffixes*: seq[string] ## e.g. Esq., II, etc.
|
|
language*: Option[string]
|
|
isPText*: bool ## true if VALUE=ptext, false by default
|
|
xParams*: seq[VC_XParam]
|
|
|
|
VC3_Nickname* = ref object of VC3_SimpleTextProperty
|
|
## Concrete class representing vCard 3.0 NICKNAME properties.
|
|
|
|
VC3_Photo* = ref object of VC3_BinaryProperty
|
|
## Concrete class representing vCard 3.0 PHOTO properties.
|
|
|
|
VC3_Bday* = ref object of VC3_Property
|
|
## Concrete class representing vCard 3.0 BDAY properties.
|
|
valueType*: Option[string] # date / date-time
|
|
value*: DateTime
|
|
|
|
VC3_AdrTypes* = enum
|
|
## Standard types for ADR values defined in RFC2426
|
|
atDom = "DOM"
|
|
atIntl = "INTL"
|
|
atPostal = "POSTAL"
|
|
atParcel = "PARCEL"
|
|
atHome = "HOME"
|
|
atWork = "WORK"
|
|
atPref = "PREF"
|
|
|
|
VC3_Adr* = ref object of VC3_Property
|
|
## Concrete class representing vCard 3.0 ADR properties.
|
|
adrType*: seq[string]
|
|
poBox*: string
|
|
extendedAdr*: string
|
|
streetAdr*: string
|
|
locality*: string
|
|
region*: string
|
|
postalCode*: string
|
|
country*: string
|
|
isPText*: bool ## `true` if `VALUE=ptext`, `false` by default
|
|
language*: Option[string]
|
|
xParams*: seq[VC_XParam]
|
|
|
|
VC3_Label* = ref object of VC3_SimpleTextProperty
|
|
## Concrete class representing vCard 3.0 LABEL properties.
|
|
adrType*: seq[string]
|
|
|
|
VC3_TelTypes* = enum
|
|
## Standard types for TEL values defined in RFC2426
|
|
ttHome = "HOME",
|
|
ttWork = "WORK",
|
|
ttPref = "PREF",
|
|
ttVoice = "VOICE",
|
|
ttFax = "FAX",
|
|
ttMsg = "MSG",
|
|
ttCell = "CELL",
|
|
ttPager = "PAGER",
|
|
ttBbs = "BBS",
|
|
ttModem = "MODEM",
|
|
ttCar = "CAR",
|
|
ttIsdn = "ISDN",
|
|
ttVideo = "VIDEO",
|
|
ttPcs = "PCS"
|
|
|
|
VC3_Tel* = ref object of VC3_Property
|
|
## Concrete class representing vCard 3.0 TEL properties.
|
|
telType*: seq[string]
|
|
value*: string
|
|
|
|
VC3_EmailType* = enum
|
|
## Standard types for EMAIL values defined in RFC2426
|
|
etInternet = "INTERNET",
|
|
etX400 = "X400"
|
|
|
|
VC3_Email* = ref object of VC3_Property
|
|
## Concrete class representing vCard 3.0 EMAIL properties.
|
|
emailType*: seq[string]
|
|
value*: string
|
|
|
|
VC3_Mailer* = ref object of VC3_SimpleTextProperty
|
|
## Concrete class representing vCard 3.0 MAILER properties.
|
|
|
|
VC3_TZ* = ref object of VC3_Property
|
|
## Concrete class representing vCard 3.0 TZ properties.
|
|
value*: string
|
|
isText*: bool ## `true` if `VALUE=text`, `false` by default
|
|
|
|
VC3_Geo* = ref object of VC3_Property
|
|
## Concrete class representing vCard 3.0 GEO properties.
|
|
lat*, long*: float
|
|
|
|
VC3_Title* = ref object of VC3_SimpleTextProperty
|
|
## Concrete class representing vCard 3.0 TITLE properties.
|
|
|
|
VC3_Role* = ref object of VC3_SimpleTextProperty
|
|
## Concrete class representing vCard 3.0 ROLE properties.
|
|
|
|
VC3_Logo* = ref object of VC3_BinaryProperty
|
|
## Concrete class representing vCard 3.0 LOGO properties.
|
|
|
|
VC3_Agent* = ref object of VC3_Property
|
|
## Concrete class representing vCard 3.0 AGENT properties.
|
|
value*: string ## either an escaped vCard object, or a URI
|
|
isInline*: bool ## `false` if `VALUE=uri`, `true` by default
|
|
|
|
VC3_Org* = ref object of VC3_Property
|
|
## Concrete class representing vCard 3.0 ORG properties.
|
|
value*: seq[string]
|
|
isPText*: bool ## `true` if `VALUE=ptext`, `false` by default
|
|
language*: Option[string]
|
|
xParams*: seq[VC_XParam]
|
|
|
|
VC3_Categories* = ref object of VC3_Property
|
|
## Concrete class representing vCard 3.0 CATEGORIES properties.
|
|
value*: seq[string]
|
|
isPText*: bool ## `true` if `VALUE=ptext`, `false` by default
|
|
language*: Option[string]
|
|
xParams*: seq[VC_XParam]
|
|
|
|
VC3_Note* = ref object of VC3_SimpleTextProperty
|
|
## Concrete class representing vCard 3.0 NOTE properties.
|
|
|
|
VC3_Prodid* = ref object of VC3_SimpleTextProperty
|
|
## Concrete class representing vCard 3.0 PRODID properties.
|
|
|
|
VC3_Rev* = ref object of VC3_Property
|
|
## Concrete class representing vCard 3.0 REV properties.
|
|
valueType*: Option[string] # date / date-time
|
|
value*: DateTime
|
|
|
|
VC3_SortString* = ref object of VC3_SimpleTextProperty
|
|
## Concrete class representing vCard 3.0 SORT-STRING properties.
|
|
|
|
VC3_Sound* = ref object of VC3_BinaryProperty
|
|
## Concrete class representing vCard 3.0 SOUND properties.
|
|
|
|
VC3_UID* = ref object of VC3_Property
|
|
## Concrete class representing vCard 3.0 UID properties.
|
|
value*: string
|
|
|
|
VC3_URL* = ref object of VC3_Property
|
|
## Concrete class representing vCard 3.0 URL properties.
|
|
value*: string
|
|
|
|
VC3_Version* = ref object of VC3_Property
|
|
## Concrete class representing vCard 3.0 VERSION properties.
|
|
value*: string # 3.0
|
|
|
|
VC3_Class* = ref object of VC3_Property
|
|
## Concrete class representing vCard 3.0 CLASS properties.
|
|
value*: string
|
|
|
|
VC3_Key* = ref object of VC3_BinaryProperty
|
|
## Concrete class representing vCard 3.0 KEY properties.
|
|
keyType*: Option[string] # x509 / pgp
|
|
|
|
VC3_XType* = ref object of VC3_SimpleTextProperty
|
|
|
|
VCard3* = ref object of VCard
|
|
## Concrete class implementing the vCard 3.0 type.
|
|
nextPropertyId: int
|
|
content*: seq[VC3_Property]
|
|
|
|
# Internal Utility/Implementation
|
|
# ===============================
|
|
|
|
template takePropertyId(vc3: VCard3): int =
|
|
vc3.nextPropertyId += 1
|
|
vc3.nextPropertyId - 1
|
|
|
|
func namesForProp(prop: VC3_PropertyName):
|
|
tuple[enumName, typeName, initFuncName, accessorFuncName: NimNode] =
|
|
|
|
var name: string = ($prop).replace("-", "")
|
|
if name.len > 1: name = name[0] & name[1..^1].toLower
|
|
return (
|
|
ident("pn" & name),
|
|
ident("VC3_" & name),
|
|
ident("newVC3_" & name),
|
|
ident(name.toLower))
|
|
|
|
## Initializers
|
|
## ============
|
|
|
|
func newVC3_Name*(value: string, group = none[string]()): VC3_Name =
|
|
return VC3_Name(name: "NAME", value: value, group: group)
|
|
|
|
func newVC3_Profile*(group = none[string]()): VC3_Profile =
|
|
return VC3_Profile(name: "PROFILE", group: group)
|
|
|
|
func newVC3_Source*(
|
|
value: string,
|
|
inclContext = false,
|
|
inclValue = false,
|
|
xParams: seq[VC_XParam] = @[],
|
|
group = none[string]()): VC3_Source =
|
|
|
|
return assignFields(
|
|
VC3_Source(
|
|
name: $pnSource,
|
|
valueType: if inclValue: some("uri")
|
|
else: none[string](),
|
|
context: if inclContext: some("word")
|
|
else: none[string]()),
|
|
value, group, xParams)
|
|
|
|
func newVC3_Fn*(
|
|
value: string,
|
|
language = none[string](),
|
|
isPText = false,
|
|
xParams: seq[VC_XParam] = @[],
|
|
group = none[string]()): VC3_Fn =
|
|
|
|
return assignFields(
|
|
VC3_Fn(name: $pnFn),
|
|
value, language, isPText, group, xParams)
|
|
|
|
func newVC3_N*(
|
|
family: seq[string] = @[],
|
|
given: seq[string] = @[],
|
|
additional: seq[string] = @[],
|
|
prefixes: seq[string] = @[],
|
|
suffixes: seq[string] = @[],
|
|
language = none[string](),
|
|
isPText = false,
|
|
xParams: seq[VC_XParam] = @[],
|
|
group = none[string]()): VC3_N =
|
|
|
|
return assignFields(
|
|
VC3_N(name: $pnN),
|
|
family, given, additional, prefixes, suffixes, language, xParams)
|
|
|
|
func newVC3_Nickname*(
|
|
value: string,
|
|
language = none[string](),
|
|
isPText = false,
|
|
xParams: seq[VC_XParam] = @[],
|
|
group = none[string]()): VC3_Nickname =
|
|
|
|
return assignFields(
|
|
VC3_Nickname(name: $pnNickname),
|
|
value, language, isPText, group, xParams)
|
|
|
|
func newVC3_Photo*(
|
|
value: string,
|
|
valueType = some("uri"),
|
|
binaryType = none[string](),
|
|
isInline = false,
|
|
group = none[string]()): VC3_Photo =
|
|
|
|
return assignFields(
|
|
VC3_Photo(name: $pnPhoto),
|
|
value, valueType, binaryType, isInline, group)
|
|
|
|
func newVC3_Bday*(
|
|
value: DateTime,
|
|
valueType = none[string](),
|
|
group = none[string]()): VC3_Bday =
|
|
|
|
return assignFields(VC3_Bday(name: $pnBday), value, valueType, group)
|
|
|
|
func newVC3_Adr*(
|
|
adrType = @[$atIntl,$atPostal,$atParcel,$atWork],
|
|
poBox = "",
|
|
extendedAdr = "",
|
|
streetAdr = "",
|
|
locality = "",
|
|
region = "",
|
|
postalCode = "",
|
|
country = "",
|
|
language = none[string](),
|
|
isPText = false,
|
|
group = none[string](),
|
|
xParams: seq[VC_XParam] = @[]): VC3_Adr =
|
|
|
|
return assignFields(
|
|
VC3_Adr(name: $pnAdr),
|
|
adrType, poBox, extendedAdr, streetAdr, locality, region, postalCode,
|
|
country, isPText, language, group, xParams)
|
|
|
|
func newVC3_Label*(
|
|
value: string,
|
|
adrType = @[$atIntl,$atPostal,$atParcel,$atWork],
|
|
language = none[string](),
|
|
isPText = false,
|
|
xParams: seq[VC_XParam] = @[],
|
|
group = none[string]()): VC3_Label =
|
|
|
|
return assignFields(
|
|
VC3_Label(name: $pnLabel),
|
|
value, adrType, language, isPText, group, xParams)
|
|
|
|
func newVC3_Tel*(
|
|
value: string,
|
|
telType = @[$ttVoice],
|
|
group = none[string]()): VC3_Tel =
|
|
|
|
return assignFields(VC3_Tel(name: $pnTel), value, telType, group)
|
|
|
|
func newVC3_Email*(
|
|
value: string,
|
|
emailType = @[$etInternet],
|
|
group = none[string]()): VC3_Email =
|
|
|
|
return assignFields(VC3_Email(name: $pnEmail), value, emailType, group)
|
|
|
|
func newVC3_Mailer*(
|
|
value: string,
|
|
language = none[string](),
|
|
isPText = false,
|
|
xParams: seq[VC_XParam] = @[],
|
|
group = none[string]()): VC3_Mailer =
|
|
|
|
return assignFields(
|
|
VC3_Mailer(name: $pnMailer),
|
|
value, language, isPText, xParams, group)
|
|
|
|
func newVC3_TZ*(value: string, isText = false, group = none[string]()): VC3_TZ =
|
|
return assignFields(VC3_TZ(name: $pnTz), value, isText, group)
|
|
|
|
func newVC3_Geo*(lat, long: float, group = none[string]()): VC3_Geo =
|
|
return assignFields(VC3_Geo(name: $pnGeo), lat, long, group)
|
|
|
|
func newVC3_Title*(
|
|
value: string,
|
|
language = none[string](),
|
|
isPText = false,
|
|
xParams: seq[VC_XParam] = @[],
|
|
group = none[string]()): VC3_Title =
|
|
|
|
return assignFields(
|
|
VC3_Title(name: $pnTitle),
|
|
value, language, isPText, xParams, group)
|
|
|
|
func newVC3_Role*(
|
|
value: string,
|
|
language = none[string](),
|
|
isPText = false,
|
|
xParams: seq[VC_XParam] = @[],
|
|
group = none[string]()): VC3_Role =
|
|
|
|
return assignFields(
|
|
VC3_Role(name: $pnRole),
|
|
value, language, isPText, xParams, group)
|
|
|
|
func newVC3_Logo*(
|
|
value: string,
|
|
valueType = some("uri"),
|
|
binaryType = none[string](),
|
|
isInline = false,
|
|
group = none[string]()): VC3_Logo =
|
|
|
|
return assignFields(
|
|
VC3_Logo(name: $pnLogo),
|
|
value, valueType, binaryType, isInline, group)
|
|
|
|
func newVC3_Agent*(
|
|
value: string,
|
|
isInline = true,
|
|
group = none[string]()): VC3_Agent =
|
|
|
|
return VC3_Agent(
|
|
name: $pnAgent,
|
|
value: value,
|
|
isInline: isInline,
|
|
group: group)
|
|
|
|
func newVC3_Org*(
|
|
value: seq[string],
|
|
isPText = false,
|
|
language = none[string](),
|
|
xParams: seq[VC_XParam] = @[],
|
|
group = none[string]()): VC3_Org =
|
|
|
|
return assignFields(
|
|
VC3_Org(name: $pnOrg),
|
|
value, isPText, language, xParams, group)
|
|
|
|
func newVC3_Categories*(
|
|
value: seq[string],
|
|
isPText = false,
|
|
language = none[string](),
|
|
xParams: seq[VC_XParam] = @[],
|
|
group = none[string]()): VC3_Categories =
|
|
|
|
return assignFields(
|
|
VC3_Categories(name: $pnCategories),
|
|
value, isPText, language, xParams, group)
|
|
|
|
func newVC3_Note*(
|
|
value: string,
|
|
language = none[string](),
|
|
isPText = false,
|
|
xParams: seq[VC_XParam] = @[],
|
|
group = none[string]()): VC3_Note =
|
|
|
|
return assignFields(
|
|
VC3_Note(name: $pnNote),
|
|
value, language, isPText, xParams, group)
|
|
|
|
func newVC3_Prodid*(
|
|
value: string,
|
|
language = none[string](),
|
|
isPText = false,
|
|
xParams: seq[VC_XParam] = @[],
|
|
group = none[string]()): VC3_Prodid =
|
|
|
|
return assignFields(
|
|
VC3_Prodid(name: $pnProdid),
|
|
value, language, isPText, xParams, group)
|
|
|
|
func newVC3_Rev*(
|
|
value: DateTime,
|
|
valueType = none[string](),
|
|
group = none[string]()): VC3_Rev =
|
|
|
|
return assignFields(VC3_Rev(name: $pnRev), value, valueType, group)
|
|
|
|
func newVC3_SortString*(
|
|
value: string,
|
|
language = none[string](),
|
|
isPText = false,
|
|
xParams: seq[VC_XParam] = @[],
|
|
group = none[string]()): VC3_SortString =
|
|
|
|
return assignFields(
|
|
VC3_SortString(name: $pnSortstring),
|
|
value, language, isPText, xParams, group)
|
|
|
|
func newVC3_Sound*(
|
|
value: string,
|
|
valueType = some("uri"),
|
|
binaryType = none[string](),
|
|
isInline = false,
|
|
group = none[string]()): VC3_Sound =
|
|
|
|
return assignFields(
|
|
VC3_Sound(name: $pnSound),
|
|
value, valueType, binaryType, isInline, group)
|
|
|
|
func newVC3_UID*(value: string, group = none[string]()): VC3_UID =
|
|
return VC3_UID(name: $pnUid, value: value, group: group)
|
|
|
|
func newVC3_URL*(value: string, group = none[string]()): VC3_URL =
|
|
return VC3_URL(name: $pnUrl, value: value, group: group)
|
|
|
|
func newVC3_Version*(group = none[string]()): VC3_Version =
|
|
return VC3_Version(name: $pnVersion, value: "3.0", group: group)
|
|
|
|
func newVC3_Class*(value: string, group = none[string]()): VC3_Class =
|
|
return VC3_Class(name: $pnClass, value: value, group: group)
|
|
|
|
func newVC3_Key*(
|
|
value: string,
|
|
valueType = none[string](),
|
|
keyType = none[string](),
|
|
isInline = false,
|
|
group = none[string]()): VC3_Key =
|
|
|
|
if valueType.isSome:
|
|
if valueType.get == $vtUri:
|
|
raise newException(ValueError,
|
|
"KEY content does not support the '" & $vtUri & "' value type")
|
|
elif valueType.get == $vtBinary and not isInline:
|
|
raise newException(ValueError,
|
|
"KEY content with VALUE=binary must also specify ENCODING=b")
|
|
elif valueType.get == $vtText and isInline:
|
|
raise newException(ValueError,
|
|
"KEY content with ENCODING=b cannot use VALUE=text")
|
|
|
|
return assignFields(
|
|
VC3_Key(name: $pnKey, binaryType: keyType),
|
|
value, valueType, keyType, isInline, group)
|
|
|
|
func newVC3_XType*(
|
|
name: string,
|
|
value: string,
|
|
language = none[string](),
|
|
isPText = false,
|
|
xParams: seq[VC_XParam] = @[],
|
|
group = none[string]()): VC3_XType =
|
|
|
|
if not name.startsWith("X-"):
|
|
raise newException(ValueError, "Extended types must begin with 'x-'.")
|
|
|
|
return assignFields(
|
|
VC3_XType(name: name),
|
|
value, language, isPText, xParams, group)
|
|
|
|
# Accessors
|
|
# =========
|
|
|
|
func forGroup*(vc3: VCard3, group: string): seq[VC3_Property] =
|
|
## Return all properties defined on the vCard associated with the given
|
|
## group.
|
|
return vc3.content.filterIt(it.group.isSome and it.group.get == group)
|
|
|
|
func groups*(vc3: VCard3): seq[string] =
|
|
## Return a list of all groups present on the vCard
|
|
result = @[]
|
|
for c in vc3.content:
|
|
if c.group.isSome:
|
|
let grp = c.group.get
|
|
if not result.contains(grp): result.add(grp)
|
|
|
|
macro genPropertyAccessors(
|
|
properties: static[openarray[(VC3_PropertyName, VC_PropCardinality)]]
|
|
): untyped =
|
|
|
|
result = newStmtList()
|
|
for (pn, pCard) in properties:
|
|
let (_, typeName, _, funcName) = namesForProp(pn)
|
|
|
|
case pCard:
|
|
of vpcAtMostOne:
|
|
let funcDef = genAstOpt({kDirtyTemplate}, funcName, typeName):
|
|
func funcName*(vc3: VCard3): Option[typeName] =
|
|
result = findFirst[typeName, VC3_Property](vc3.content)
|
|
funcDef[6].insert(0, newCommentStmtNode(
|
|
"Return the single " & $pn & " property (if present)."))
|
|
result.add(funcDef)
|
|
|
|
|
|
of vpcExactlyOne:
|
|
let funcDef = genAstOpt({kDirtyTemplate}, funcName, pn, typeName):
|
|
func funcName*(vc3: VCard3): typeName =
|
|
let props = findAll[typeName, VC3_Property](vc3.content)
|
|
if props.len != 1:
|
|
raise newException(ValueError,
|
|
"VCard should have exactly one $# property, but $# were found" %
|
|
[$pn, $props.len])
|
|
result = props[0]
|
|
funcDef[6].insert(0, newCommentStmtNode(
|
|
"Return the " & $pn & " property."))
|
|
result.add(funcDef)
|
|
|
|
of vpcAtLeastOne, vpcAny:
|
|
let funcDef = genAstOpt({kDirtyTemplate}, funcName, typeName):
|
|
func funcName*(vc3: VCard3): seq[typeName] =
|
|
result = findAll[typeName, VC3_Property](vc3.content)
|
|
funcDef[6].insert(0, newCommentStmtNode(
|
|
"Return all instances of the " & $pn & " property."))
|
|
result.add(funcDef)
|
|
|
|
genPropertyAccessors(propertyCardMap.pairs.toSeq -->
|
|
filter(not [pnVersion].contains(it[0])))
|
|
|
|
func version*(vc3: VCard3): VC3_Version =
|
|
## Return the VERSION property.
|
|
let found = findFirst[VC3_Version, VC3_Property](vc3.content)
|
|
if found.isSome: return found.get
|
|
else: return VC3_Version(
|
|
propertyId: vc3.content.len + 1,
|
|
group: none[string](),
|
|
name: "VERSION",
|
|
value: "3.0")
|
|
|
|
func xTypes*(vc3: VCard3): seq[VC3_XType] = findAll[VC3_XType, VC3_Property](vc3.content)
|
|
## Return all extended properties (starting with `x-`).
|
|
|
|
# Setters
|
|
# =============================================================================
|
|
|
|
func set*[T: VC3_Property](vc3: VCard3, content: varargs[T]): void =
|
|
## Set the given property on the vCard. This will replace the first existing
|
|
## instance of this property or append this as a new property if there is no
|
|
## existing instance of this property. This is useful for properties which
|
|
## should only appear once within the vCard.
|
|
for c in content:
|
|
var nc = c
|
|
let existingIdx = vc3.content.indexOfIt(it of T)
|
|
if existingIdx < 0:
|
|
nc.propertyId = vc3.takePropertyId
|
|
vc3.content.add(nc)
|
|
else:
|
|
nc.propertyId = vc3.content[existingIdx].propertyId
|
|
vc3.content[existingIdx] = nc
|
|
|
|
func add*[T: VC3_Property](vc3: VCard3, content: varargs[T]): void =
|
|
## Add a new property to the end of the vCard.
|
|
for c in content:
|
|
var nc = c
|
|
nc.propertyId = vc3.takePropertyId
|
|
vc3.content.add(nc)
|
|
|
|
func updateOrAdd*[T: VC3_Property](vc3: VCard3, content: seq[T]): VCard3 =
|
|
for c in content:
|
|
let existingIdx = vc3.content.indexOfIt(it.propertyId == c.propertyId)
|
|
if existingIdx < 0: vc3.content.add(c)
|
|
else: c.content[existingIdx] = c
|
|
|
|
# Output
|
|
# =============================================================================
|
|
|
|
func nameWithGroup(s: VC3_Property): string =
|
|
if s.group.isSome: s.group.get & "." & s.name
|
|
else: s.name
|
|
|
|
func serialize(xp: seq[VC_XParam]): string =
|
|
return (xp --> map(";" & it.name & "=" & it.value)).join("")
|
|
|
|
func serialize(s: VC3_Source): string =
|
|
result = s.nameWithGroup
|
|
if s.valueType.isSome: result &= ";VALUE=" & s.valueType.get
|
|
if s.context.isSome: result &= ";CONTEXT=" & s.context.get
|
|
result &= serialize(s.xParams)
|
|
result &= ":" & s.value
|
|
|
|
func serializeTextValue(value: string): string =
|
|
value.multiReplace([
|
|
("\\", "\\\\"),
|
|
("\n", "\\n"),
|
|
(";", "\\;"),
|
|
(",", "\\,")
|
|
])
|
|
|
|
func serializeTextValues(values: seq[string], sep: string): string =
|
|
(values --> map(serializeTextValue(it))).join(sep)
|
|
|
|
func serialize(n: VC3_N): string =
|
|
result = n.nameWithGroup
|
|
if n.isPText: result &= ";VALUE=ptext"
|
|
if n.language.isSome: result &= ";LANGUAGE=" & n.language.get
|
|
result &= serialize(n.xParams)
|
|
result &= ":" &
|
|
serializeTextValues(n.family, ",") & ";" &
|
|
serializeTextValues(n.given, ",") & ";" &
|
|
serializeTextValues(n.additional, ",") & ";" &
|
|
serializeTextValues(n.prefixes, ",") & ";" &
|
|
serializeTextValues(n.suffixes, ",")
|
|
|
|
func serialize(b: VC3_Bday): string =
|
|
result = b.nameWithGroup
|
|
if b.valueType.isSome and b.valueType.get == "date-time":
|
|
result &= ";VALUE=date-time:" & b.value.format(DATETIME_FMT)
|
|
else:
|
|
result &= ";VALUE=date:" & b.value.format(DATE_FMT)
|
|
|
|
func serialize(a: VC3_Adr): string =
|
|
result = a.nameWithGroup
|
|
if a.adrType.len > 0: result &= ";TYPE=" & a.adrType.join(",")
|
|
if a.isPText: result &= ";VALUE=ptext"
|
|
if a.language.isSome: result &= ";LANGUAGE=" & a.language.get
|
|
result &= serialize(a.xParams)
|
|
result &= ":" &
|
|
serializeTextValue(a.poBox) & ";" &
|
|
serializeTextValue(a.extendedAdr) & ";" &
|
|
serializeTextValue(a.streetAdr) & ";" &
|
|
serializeTextValue(a.locality) & ";" &
|
|
serializeTextValue(a.region) & ";" &
|
|
serializeTextValue(a.postalCode) & ";" &
|
|
serializeTextValue(a.country)
|
|
|
|
proc serialize(t: VC3_Tel): string =
|
|
result = t.nameWithGroup
|
|
if t.telType.len > 0: result &= ";TYPE=" & t.telType.join(",")
|
|
result &= ":" & t.value
|
|
|
|
proc serialize(t: VC3_Email): string =
|
|
result = t.nameWithGroup
|
|
if t.emailType.len > 0: result &= ";TYPE=" & t.emailType.join(",")
|
|
result &= ":" & t.value
|
|
|
|
func serialize(s: VC3_SimpleTextProperty): string =
|
|
result = s.nameWithGroup
|
|
if s.isPText: result &= ";VALUE=ptext"
|
|
if s.language.isSome: result &= ";LANGUAGE=" & s.language.get
|
|
result &= serialize(s.xParams)
|
|
result &= ":" & serializeTextValue(s.value)
|
|
|
|
proc serialize(b: VC3_BinaryProperty): string =
|
|
result = b.nameWithGroup
|
|
if b.valueType.isSome: result &= ";VALUE=" & b.valueType.get
|
|
if b.isInline: result &= ";ENCODING=b"
|
|
if b.binaryType.isSome: result &= ";TYPE=" & b.binaryType.get
|
|
result &= ":"
|
|
if b.isInline: result &= base64.encode(b.value)
|
|
else: result &= b.value
|
|
|
|
proc serialize(z: VC3_TZ): string =
|
|
result = z.nameWithGroup
|
|
if z.isText: result &= ";VALUE=text"
|
|
result &= ":" & z.value
|
|
|
|
proc serialize(g: VC3_Geo): string =
|
|
result = g.nameWithGroup & ":" & $g.lat & ";" & $g.long
|
|
|
|
proc serialize(a: VC3_Agent): string =
|
|
result = a.nameWithGroup
|
|
if not a.isInline: result &= ";VALUE=uri"
|
|
result &= ":" & a.value
|
|
|
|
proc serialize(o: VC3_Org): string =
|
|
result = o.nameWithGroup
|
|
if o.isPText: result &= ";VALUE=ptext"
|
|
if o.language.isSome: result &= ";LANGUAGE=" & o.language.get
|
|
result &= serialize(o.xParams)
|
|
result &= ":" & serializeTextValues(o.value, ",")
|
|
|
|
proc serialize(c: VC3_Categories): string =
|
|
result = c.nameWithGroup
|
|
if c.isPText: result &= ";VALUE=ptext"
|
|
if c.language.isSome: result &= ";LANGUAGE=" & c.language.get
|
|
result &= serialize(c.xParams)
|
|
result &= ":" & serializeTextValues(c.value, ",")
|
|
|
|
proc serialize(r: VC3_Rev): string =
|
|
result = r.nameWithGroup
|
|
if r.valueType.isSome and r.valueType.get == "date-time":
|
|
result &= ";VALUE=date-time:" & r.value.format(DATETIME_FMT)
|
|
elif r.valueType.isSome and r.valueType.get == "date":
|
|
result &= ";VALUE=date:" & r.value.format(DATE_FMT)
|
|
else:
|
|
result &= r.value.format(DATETIME_FMT)
|
|
|
|
proc serialize(u: VC3_UID | VC3_URL | VC3_VERSION | VC3_Class): string =
|
|
result = u.nameWithGroup & ":" & u.value
|
|
|
|
proc serialize(c: VC3_Property): string =
|
|
if c of VC3_Name: return c.nameWithGroup & ":" & cast[VC3_Name](c).value
|
|
elif c of VC3_Profile: return c.nameWithGroup & ":VCARD"
|
|
elif c of VC3_Source: return serialize(cast[VC3_Source](c))
|
|
elif c of VC3_N: return serialize(cast[VC3_N](c))
|
|
elif c of VC3_Bday: return serialize(cast[VC3_Bday](c))
|
|
elif c of VC3_Adr: return serialize(cast[VC3_Adr](c))
|
|
elif c of VC3_Tel: return serialize(cast[VC3_Tel](c))
|
|
elif c of VC3_Email: return serialize(cast[VC3_Email](c))
|
|
elif c of VC3_TZ: return serialize(cast[VC3_TZ](c))
|
|
elif c of VC3_Geo: return serialize(cast[VC3_Geo](c))
|
|
elif c of VC3_Agent: return serialize(cast[VC3_Agent](c))
|
|
elif c of VC3_Org: return serialize(cast[VC3_Org](c))
|
|
elif c of VC3_Categories: return serialize(cast[VC3_Categories](c))
|
|
elif c of VC3_Rev: return serialize(cast[VC3_Rev](c))
|
|
elif c of VC3_UID: return serialize(cast[VC3_UID](c))
|
|
elif c of VC3_URL: return serialize(cast[VC3_URL](c))
|
|
elif c of VC3_Version: return serialize(cast[VC3_Version](c))
|
|
elif c of VC3_Class: return serialize(cast[VC3_Class](c))
|
|
elif c of VC3_SimpleTextProperty:
|
|
return serialize(cast[VC3_SimpleTextProperty](c))
|
|
elif c of VC3_BinaryProperty:
|
|
return serialize(cast[VC3_BinaryProperty](c))
|
|
|
|
proc `$`*(vc3: VCard3): string =
|
|
## Serialize a vCard into its textual representation.
|
|
result = "BEGIN:VCARD" & CRLF
|
|
result &= "VERSION:3.0" & CRLF
|
|
for c in vc3.content.filterIt(not (it of VC3_Version)):
|
|
result &= foldContentLine(serialize(c)) & CRLF
|
|
result &= "END:VCARD" & CRLF
|
|
|
|
|
|
# Parsing
|
|
# =============================================================================
|
|
|
|
proc readParamValue(p: var VCardParser): string =
|
|
## Read a single parameter value at the current read position or error.
|
|
p.setBookmark
|
|
if p.peek == '"':
|
|
discard p.read
|
|
while QSAFE_CHARS.contains(p.peek): discard p.read
|
|
if p.read != '"':
|
|
p.error("quoted parameter value expected to end with a " &
|
|
"double quote (\")")
|
|
result = p.readSinceBookmark[1 ..< ^1]
|
|
else:
|
|
while SAFE_CHARS.contains(p.peek): discard p.read
|
|
result = p.readSinceBookmark
|
|
|
|
p.unsetBookmark
|
|
|
|
if result.len == 0:
|
|
p.error("expected to read a parameter value")
|
|
|
|
proc readParams(p: var VCardParser): seq[VC_Param] =
|
|
## Read all parameters for the current content line at the current read head.
|
|
result = @[]
|
|
while p.peek == ';':
|
|
discard p.read
|
|
var param: VCParam = (p.readName, @[])
|
|
p.expect("=", true)
|
|
param.values.add(p.readParamValue)
|
|
while p.peek == ',':
|
|
discard p.read
|
|
param.values.add(p.readParamValue)
|
|
result.add(param)
|
|
|
|
proc getXParams(params: openarray[VCParam]): seq[VC_XParam] =
|
|
## Filter out and return only the non-standard parameters starting with "x-"
|
|
|
|
let ps = params.toSeq
|
|
return ps -->
|
|
filter(it.name.startsWith("x-")).
|
|
map((name: it.name, value: it.values.join(",")))
|
|
|
|
proc readTextValue(p: var VCardParser, ignorePrefix: set[char] = {}): string =
|
|
## Read a text-value (defined by RFC2426) from the current read position.
|
|
## text-value is a more constrained definition of possible value characters
|
|
## used in content types like N and ADR.
|
|
|
|
result = newStringOfCap(32)
|
|
|
|
let validChars = SAFE_CHARS + {'"', ':', '\\'}
|
|
while ignorePrefix.contains(p.peek): discard p.read
|
|
while validChars.contains(p.peek):
|
|
let c = p.read
|
|
|
|
if c == '\\':
|
|
case p.peek
|
|
of '\\', ';', ',': result.add(p.read)
|
|
of 'n', 'N':
|
|
result.add('\n')
|
|
discard p.read
|
|
else:
|
|
p.error("invalid character escape: '\\$1'" % [$p.read])
|
|
else: result.add(c)
|
|
|
|
proc readTextValueList(
|
|
p: var VCardParser,
|
|
seps: set[char] = {','},
|
|
ifPrefix = none[char]()
|
|
): seq[string] =
|
|
## Read in a list of multiple text-value (defined by RFC2426) values from the
|
|
## current read position. This is used, for example, by the N content type.
|
|
|
|
if ifPrefix.isSome:
|
|
if p.peek != ifPrefix.get: return @[]
|
|
discard p.read
|
|
|
|
result = @[p.readTextValue]
|
|
while seps.contains(p.peek): result.add(p.readTextValue(ignorePrefix = seps))
|
|
|
|
proc decodeTextValue(p: var VCardParser, value: string): string =
|
|
result = newStringOfCap(value.len)
|
|
var idx = 0
|
|
|
|
while idx < value.len:
|
|
let c = value[idx]
|
|
if c != '\\':
|
|
result.add(c)
|
|
inc idx
|
|
continue
|
|
|
|
if idx + 1 >= value.len:
|
|
p.error("invalid character escape: '\\'")
|
|
|
|
case value[idx + 1]
|
|
of '\\', ';', ',':
|
|
result.add(value[idx + 1])
|
|
of 'n', 'N':
|
|
result.add('\n')
|
|
else:
|
|
p.error("invalid character escape: '\\$1'" % [$value[idx + 1]])
|
|
|
|
inc idx, 2
|
|
|
|
proc readBinaryValue(
|
|
p: var VCardParser,
|
|
isInline: bool,
|
|
propName: string
|
|
): string =
|
|
let value = p.readValue
|
|
if not isInline:
|
|
return value
|
|
|
|
try:
|
|
return base64.decode(value)
|
|
except ValueError:
|
|
p.error("invalid base64 value for the " & propName & " content type")
|
|
|
|
proc parseContentLines*(p: var VCardParser): seq[VC3_Property] =
|
|
result = @[]
|
|
|
|
macro assignCommon(assign: untyped): untyped =
|
|
result = assign
|
|
result.add(newTree(nnkExprEqExpr, ident("group"), ident("group")))
|
|
|
|
result.add(newTree(nnkExprEqExpr,
|
|
ident("language"),
|
|
newCall(ident("getSingleValue"),
|
|
ident("params"),
|
|
newStrLitNode("LANGUAGE"))))
|
|
|
|
result.add(newTree(nnkExprEqExpr,
|
|
ident("isPText"),
|
|
newCall(ident("existsWithValue"),
|
|
ident("params"),
|
|
newStrLitNode("VALUE"),
|
|
newTree(nnkPrefix, ident("$"), ident("vtPText")))))
|
|
|
|
result.add(newTree(nnkExprEqExpr,
|
|
ident("xParams"),
|
|
newCall(ident("getXParams"), ident("params"))))
|
|
|
|
while true:
|
|
let group = p.readGroup
|
|
let name = p.readName
|
|
if name == "END":
|
|
p.expect(":VCARD" & CRLF)
|
|
break
|
|
let params = p.readParams
|
|
p.expect(":")
|
|
|
|
case name
|
|
|
|
of $pnName:
|
|
p.validateNoParameters(params, "NAME")
|
|
result.add(newVC3_Name(p.readValue, group))
|
|
|
|
of $pnProfile:
|
|
if p.readValue.toUpper != "VCARD":
|
|
p.error("the value of the PROFILE content type must be \"$1\"" %
|
|
["vcard"])
|
|
p.validateNoParameters(params, "PROFILE")
|
|
result.add(newVC3_Profile(group))
|
|
|
|
of $pnSource:
|
|
p.validateRequiredParameters(params,
|
|
[("CONTEXT", "word"), ("VALUE", "uri")])
|
|
|
|
result.add(newVC3_Source(
|
|
group = group,
|
|
value = p.readValue,
|
|
inclContext = params.existsWithValue("CONTEXT", "WORD"),
|
|
inclValue = params.existsWithValue("VALUE", $vtUri),
|
|
xParams = params.getXParams))
|
|
|
|
of $pnFn:
|
|
result.add(assignCommon(newVC3_Fn(
|
|
value = p.decodeTextValue(p.readValue))))
|
|
|
|
of $pnN:
|
|
result.add(assignCommon(newVC3_N(
|
|
family = p.readTextValueList,
|
|
given = p.readTextValueList(ifPrefix = some(';')),
|
|
additional = p.readTextValueList(ifPrefix = some(';')),
|
|
prefixes = p.readTextValueList(ifPrefix = some(';')),
|
|
suffixes = p.readTextValueList(ifPrefix = some(';')))))
|
|
|
|
of $pnNickname:
|
|
result.add(assignCommon(newVC3_Nickname(
|
|
value = p.decodeTextValue(p.readValue))))
|
|
|
|
of $pnPhoto:
|
|
let isInline = params.existsWithValue("ENCODING", "B")
|
|
result.add(newVC3_Photo(
|
|
group = group,
|
|
value = p.readBinaryValue(isInline, "PHOTO"),
|
|
valueType = params.getSingleValue("VALUE"),
|
|
binaryType = params.getSingleValue("TYPE"),
|
|
isInline = isInline))
|
|
|
|
of $pnBday:
|
|
let valueType = params.getSingleValue("VALUE")
|
|
let valueStr = p.readValue
|
|
var value: DateTime
|
|
|
|
try:
|
|
if valueType.isSome and valueType.get == $vtDate:
|
|
value = parseDate(valueStr)
|
|
elif valueType.isSome and valueType.get == $vtDateTime:
|
|
value = parseDateTime(valueStr)
|
|
elif valueType.isSome:
|
|
p.error("invalid VALUE for BDAY content. " &
|
|
"Expected '" & $vtDate & "' or '" & $vtDateTime & "'")
|
|
else:
|
|
value = parseDateOrDateTime(valueStr)
|
|
except ValueError:
|
|
p.error("invalid date or date-time value: $1" % [valueStr])
|
|
|
|
result.add(newVC3_Bday(
|
|
group = group,
|
|
valueType = valueType,
|
|
value = value))
|
|
|
|
of $pnAdr:
|
|
result.add(assignCommon(newVC3_Adr(
|
|
adrType = params.getMultipleValues("TYPE"),
|
|
poBox = p.readTextValue,
|
|
extendedAdr = p.readTextValue(ignorePrefix = {';'}),
|
|
streetAdr = p.readTextValue(ignorePrefix = {';'}),
|
|
locality = p.readTextValue(ignorePrefix = {';'}),
|
|
region = p.readTextValue(ignorePrefix = {';'}),
|
|
postalCode = p.readTextValue(ignorePrefix = {';'}),
|
|
country = p.readTextValue(ignorePrefix = {';'}))))
|
|
|
|
of $pnLabel:
|
|
result.add(assignCommon(newVC3_Label(
|
|
value = p.decodeTextValue(p.readValue),
|
|
adrType = params.getMultipleValues("TYPE"))))
|
|
|
|
of $pnTel:
|
|
result.add(newVC3_Tel(
|
|
group = group,
|
|
value = p.readValue,
|
|
telType = params.getMultipleValues("TYPE")))
|
|
|
|
of $pnEmail:
|
|
result.add(newVC3_Email(
|
|
group = group,
|
|
value = p.readValue,
|
|
emailType = params.getMultipleValues("TYPE")))
|
|
|
|
of $pnMailer:
|
|
result.add(assignCommon(newVC3_Mailer(
|
|
value = p.decodeTextValue(p.readValue))))
|
|
|
|
of $pnTz:
|
|
result.add(newVC3_Tz(
|
|
value = p.readValue,
|
|
isText = params.existsWithValue("VALUE", "TEXT")))
|
|
|
|
of $pnGeo:
|
|
let rawValue = p.readValue
|
|
try:
|
|
let partsStr = rawValue.split(';')
|
|
result.add(newVC3_Geo(
|
|
group = group,
|
|
lat = parseFloat(partsStr[0]),
|
|
long = parseFloat(partsStr[1])
|
|
))
|
|
except ValueError:
|
|
p.error("expected two float values separated by ';' for the GEO " &
|
|
"content type but received '" & rawValue & "'")
|
|
|
|
of $pnTitle:
|
|
result.add(assignCommon(newVC3_Title(
|
|
value = p.decodeTextValue(p.readValue))))
|
|
|
|
of $pnRole:
|
|
result.add(assignCommon(newVC3_Role(
|
|
value = p.decodeTextValue(p.readValue))))
|
|
|
|
of $pnLogo:
|
|
let isInline = params.existsWithValue("ENCODING", "B")
|
|
result.add(newVC3_Logo(
|
|
group = group,
|
|
value = p.readBinaryValue(isInline, "LOGO"),
|
|
valueType = params.getSingleValue("VALUE"),
|
|
binaryType = params.getSingleValue("TYPE"),
|
|
isInline = isInline))
|
|
|
|
of $pnAgent:
|
|
let valueParam = params.getSingleValue("VALUE")
|
|
if valueParam.isSome and valueParam.get != $vtUri:
|
|
p.error("the VALUE parameter must be set to '" & $vtUri &
|
|
"' if present on the AGENT content type, but it was '" &
|
|
valueParam.get & "'")
|
|
|
|
result.add(newVC3_Agent(
|
|
group = group,
|
|
value = p.readValue,
|
|
isInline = valueParam.isNone))
|
|
|
|
of $pnOrg:
|
|
result.add(assignCommon(newVC3_Org(
|
|
value = p.readTextValueList(seps = {';'}))))
|
|
|
|
of $pnCategories:
|
|
result.add(assignCommon(newVC3_Categories(
|
|
value = p.readTextValueList())))
|
|
|
|
of $pnNote:
|
|
result.add(assignCommon(newVC3_Note(value = p.readTextValue)))
|
|
|
|
of $pnProdid:
|
|
result.add(assignCommon(newVC3_Prodid(
|
|
value = p.decodeTextValue(p.readValue))))
|
|
|
|
of $pnRev:
|
|
let valueType = params.getSingleValue("VALUE")
|
|
let valueStr = p.readValue
|
|
var value: DateTime
|
|
|
|
try:
|
|
if valueType.isSome and valueType.get == $vtDate:
|
|
value = parseDate(valueStr)
|
|
elif valueType.isSome and valueType.get == $vtDateTime:
|
|
value = parseDateTime(valueStr)
|
|
elif valueType.isSome:
|
|
p.error("invalid VALUE for BDAY content. " &
|
|
"Expected '" & $vtDate & "' or '" & $vtDateTime & "'")
|
|
else:
|
|
value = parseDateOrDateTime(valueStr)
|
|
except ValueError:
|
|
p.error("invalid date or date-time value: $1" % [valueStr])
|
|
|
|
result.add(newVC3_Rev(
|
|
group = group,
|
|
value = value,
|
|
valueType = valueType
|
|
))
|
|
|
|
of $pnSortString:
|
|
result.add(assignCommon(newVC3_SortString(
|
|
value = p.decodeTextValue(p.readValue))))
|
|
|
|
of $pnSound:
|
|
let isInline = params.existsWithValue("ENCODING", "B")
|
|
result.add(newVC3_Sound(
|
|
group = group,
|
|
value = p.readBinaryValue(isInline, "SOUND"),
|
|
valueType = params.getSingleValue("VALUE"),
|
|
binaryType = params.getSingleValue("TYPE"),
|
|
isInline = isInline))
|
|
|
|
of $pnUid:
|
|
result.add(newVC3_UID(group = group, value = p.readValue))
|
|
|
|
of $pnUrl:
|
|
result.add(newVC3_URL(group = group, value = p.readValue))
|
|
|
|
of $pnVersion:
|
|
p.expect("3.0")
|
|
p.validateNoParameters(params, "VERSION")
|
|
result.add(newVC3_Version(group = group))
|
|
|
|
of $pnClass:
|
|
result.add(newVC3_Class(group = group, value = p.readValue))
|
|
|
|
of $pnKey:
|
|
let valueType = params.getSingleValue("VALUE")
|
|
let isInline = params.existsWithValue("ENCODING", "B")
|
|
if valueType.isSome:
|
|
if valueType.get == $vtUri:
|
|
p.error("invalid VALUE for KEY content. " &
|
|
"Expected '" & $vtText & "' or '" & $vtBinary & "'")
|
|
elif valueType.get == $vtBinary and not isInline:
|
|
p.error("KEY content with VALUE=binary must also specify ENCODING=b")
|
|
elif valueType.get == $vtText and isInline:
|
|
p.error("KEY content with ENCODING=b cannot use VALUE=text")
|
|
result.add(newVC3_Key(
|
|
group = group,
|
|
value = p.readBinaryValue(isInline, "KEY"),
|
|
valueType = valueType,
|
|
keyType = params.getSingleValue("TYPE"),
|
|
isInline = isInline))
|
|
|
|
else:
|
|
if not name.startsWith("X-"):
|
|
p.error("unrecognized content type: '$1'" % [name])
|
|
|
|
result.add(newVC3_XType(
|
|
name = name,
|
|
value = p.readValue,
|
|
language = params.getSingleValue("LANGUAGE"),
|
|
isPText = params.existsWithValue("VALUE", "PTEXT"),
|
|
group = group,
|
|
xParams = params -->
|
|
filter(not ["value", "language"].contains(it.name)).
|
|
map((name: it.name, value: it.values.join(",")))))
|
|
|
|
p.expect("\r\n")
|
|
|
|
#[
|
|
Simplified Parsing Diagram
|
|
|
|
```mermaid
|
|
stateDiagram-v2
|
|
[*] --> StartVCard
|
|
StartVCard --> ContentLine: "BEGIN VCARD" CRLF
|
|
ContentLine --> EndVCard: "END VCARD" CRLF
|
|
ContentLine --> Name
|
|
Name --> Name: 0-9/a-z/-/.
|
|
Name --> Param: SEMICOLON
|
|
Name --> Value: COLON
|
|
Param --> Value: COLON
|
|
Value --> ContentLine: CRLF
|
|
|
|
state Param {
|
|
[*] --> ParamName
|
|
ParamName --> ParamName: 0-9/a-z/-/.
|
|
ParamName --> ParamValue: "="
|
|
ParamValue --> ParamValue: ","
|
|
ParamValue --> PText
|
|
ParamValue --> Quoted
|
|
PText --> PText: SAFE-CHAR
|
|
PText --> [*]
|
|
Quoted --> Quoted: QSAFE-CHAR
|
|
Quoted --> [*]
|
|
}
|
|
```
|
|
]#
|
|
|
|
## Private Function Unit Tests
|
|
## ============================================================================
|
|
proc runVCard3PrivateTests*() =
|
|
|
|
proc initParser(input: string): VCardParser =
|
|
result = VCardParser(filename: "private unittests")
|
|
lexer.open(result, newStringStream(input))
|
|
|
|
# "readGroup":
|
|
block:
|
|
var p = initParser("mygroup.BEGIN:VCARD")
|
|
let g = p.readGroup
|
|
assert g.isSome
|
|
assert g.get == "mygroup"
|
|
|
|
# "readGroup with hyphen":
|
|
block:
|
|
var p = initParser("item-1.BEGIN:VCARD")
|
|
let g = p.readGroup
|
|
assert g.isSome
|
|
assert g.get == "item-1"
|
|
|
|
# "readGroup without group":
|
|
block:
|
|
var p = initParser("BEGIN:VCARD")
|
|
assert p.readGroup.isNone
|
|
|
|
# "expect (case-sensitive)":
|
|
block:
|
|
var p = initParser("BEGIN:VCARD")
|
|
p.expect("BEGIN", true)
|
|
|
|
try:
|
|
p.expect(":vcard", true)
|
|
assert "" == "expect should have raised an error"
|
|
except CatchableError: discard
|
|
|
|
# "expect (case-insensitive)":
|
|
block:
|
|
var p = initParser("BEGIN:VCARD")
|
|
p.expect("begin")
|
|
|
|
try:
|
|
p.expect("begin")
|
|
assert "" == "expect should have raised an error"
|
|
except CatchableError: discard
|
|
|
|
# "readName":
|
|
block:
|
|
var p = initParser("TEL;tel;x-Example;x-Are1+Name")
|
|
assert p.readName == "TEL"
|
|
assert p.read == ';'
|
|
assert p.readName == "TEL"
|
|
assert p.read == ';'
|
|
assert p.readName == "X-EXAMPLE"
|
|
assert p.read == ';'
|
|
assert p.readName == "X-ARE1"
|
|
|
|
try:
|
|
discard p.readName
|
|
assert "" == "readName should have raised an error"
|
|
except CatchableError: discard
|
|
|
|
# "readParamValue":
|
|
block:
|
|
var p = initParser("TEL;TYPE=WORK;TYPE=Fun&Games%:+15551234567")
|
|
assert p.readName == "TEL"
|
|
assert p.read == ';'
|
|
assert p.readName == "TYPE"
|
|
assert p.read == '='
|
|
assert p.readParamValue == "WORK"
|
|
assert p.read == ';'
|
|
assert p.readName == "TYPE"
|
|
assert p.read == '='
|
|
assert p.readParamValue == "Fun&Games%"
|
|
|
|
# "readParamValue quoted":
|
|
block:
|
|
var p = initParser("FN;LANGUAGE=\"en\":John Smith")
|
|
assert p.readName == "FN"
|
|
assert p.read == ';'
|
|
assert p.readName == "LANGUAGE"
|
|
assert p.read == '='
|
|
assert p.readParamValue == "en"
|
|
|
|
# "readParamValue quoted with comma":
|
|
block:
|
|
var p = initParser("LABEL;TYPE=\"HOME,POSTAL\":123 Main St.")
|
|
assert p.readName == "LABEL"
|
|
assert p.read == ';'
|
|
assert p.readName == "TYPE"
|
|
assert p.read == '='
|
|
assert p.readParamValue == "HOME,POSTAL"
|
|
|
|
# "readParams":
|
|
block:
|
|
var p = initParser("TEL;TYPE=WORK;TYPE=Fun&Games%,Extra:+15551234567")
|
|
assert p.readName == "TEL"
|
|
let params = p.readParams
|
|
assert params.len == 2
|
|
assert params[0].name == "TYPE"
|
|
assert params[0].values.len == 1
|
|
assert params[0].values[0] == "WORK"
|
|
assert params[1].name == "TYPE"
|
|
assert params[1].values.len == 2
|
|
assert params[1].values[0] == "Fun&Games%"
|
|
assert params[1].values[1] == "Extra"
|
|
|
|
# "readParams quoted":
|
|
block:
|
|
var p = initParser("FN;LANGUAGE=\"en\":John Smith")
|
|
assert p.readName == "FN"
|
|
let params = p.readParams
|
|
assert params.len == 1
|
|
assert params[0].name == "LANGUAGE"
|
|
assert params[0].values == @["en"]
|
|
|
|
# "readValue":
|
|
block:
|
|
var p = initParser("TEL;TYPE=WORK:+15551234567\r\nFN:John Smith\r\n")
|
|
assert p.skip("TEL")
|
|
discard p.readParams
|
|
assert p.read == ':'
|
|
assert p.readValue == "+15551234567"
|
|
p.expect("\r\n")
|
|
assert p.readName == "FN"
|
|
discard p.readParams
|
|
assert p.read == ':'
|
|
assert p.readValue == "John Smith"
|
|
|
|
# "readTextValueList":
|
|
block:
|
|
var p = initParser("Public;John;Quincey,Adams;Rev.;Esq:limited\r\n")
|
|
assert p.readTextValueList == @["Public"]
|
|
assert p.readTextValueList(ifPrefix = some(';')) == @["John"]
|
|
assert p.readTextValueList(ifPrefix = some(';')) == @["Quincey", "Adams"]
|
|
assert p.readTextValueList(ifPrefix = some(';')) == @["Rev."]
|
|
assert p.readTextValueList(ifPrefix = some(';')) == @["Esq:limited"]
|
|
assert p.readTextValueList(ifPrefix = some(';')) == newSeq[string]()
|
|
|
|
# "existsWithValue":
|
|
block:
|
|
var p = initParser(";TYPE=WORK;TYPE=VOICE;TYPE=CELL")
|
|
let params = p.readParams
|
|
assert params.existsWithValue("TYPE", "WORK")
|
|
assert params.existsWithValue("TYPE", "CELL")
|
|
assert not params.existsWithValue("TYPE", "ISDN")
|
|
|
|
# "getSingleValue":
|
|
block:
|
|
var p = initParser(";TYPE=WORK;TYPE=VOICE;TYPE=CELL")
|
|
let params = p.readParams
|
|
let val = params.getSingleValue("TYPE")
|
|
assert val.isSome
|
|
assert val.get == "WORK"
|
|
assert params.getSingleValue("VALUE").isNone
|
|
|
|
# "getMultipleValues":
|
|
block:
|
|
var p = initParser(";TYPE=WORK;TYPE=VOICE;TYPE=CELL")
|
|
let params = p.readParams
|
|
assert params.getMultipleValues("TYPE") == @["WORK", "VOICE", "CELL"]
|
|
assert params.getMultipleValues("VALUE") == newSeq[string]()
|
|
|
|
# "validateNoParameters":
|
|
block:
|
|
var p = initParser(";TYPE=WORK;TYPE=VOICE;TYPE=CELL")
|
|
let params = p.readParams
|
|
p.validateNoParameters(@[], "TEST")
|
|
try:
|
|
p.validateNoParameters(params, "TEST")
|
|
assert "" == "validateNoParameters should have errored"
|
|
except CatchableError: discard
|
|
|
|
# "validateRequredParameters":
|
|
block:
|
|
var p = initParser(";CONTEXT=word;VALUE=uri;TYPE=CELL")
|
|
let params = p.readParams
|
|
p.validateRequiredParameters(params,
|
|
[("VALUE", "uri"), ("CONTEXT", "word")])
|
|
|
|
try:
|
|
p.validateRequiredParameters(params, [("TYPE", "VOICE")])
|
|
assert "" == "validateRequiredParameters should have errored"
|
|
except CatchableError: discard
|
|
|
|
when isMainModule: runVCard3PrivateTests()
|