From 8e17a961458581a8c28dd5075dca34866b5c0b90 Mon Sep 17 00:00:00 2001 From: Jonathan Bernard Date: Sat, 28 Mar 2026 22:51:41 -0500 Subject: [PATCH] Add typed vCard 4 reduced-precision date constructors --- src/vcard/vcard4.nim | 128 +++++++++++++++++++++++++++++++++++++++++++ tests/tvcard4.nim | 15 +++++ 2 files changed, 143 insertions(+) diff --git a/src/vcard/vcard4.nim b/src/vcard/vcard4.nim index 97cec9a..b0c333d 100644 --- a/src/vcard/vcard4.nim +++ b/src/vcard/vcard4.nim @@ -405,6 +405,80 @@ func normalizeStructuredParamValues(values: seq[string]): seq[string] = else: values +func padInt(value, width: int): string = + ($value).align(width, '0') + +func formatTypedDateAndOrTimeValue( + year: Option[int] = none[int](), + month: Option[int] = none[int](), + day: Option[int] = none[int](), + hour: Option[int] = none[int](), + minute: Option[int] = none[int](), + second: Option[int] = none[int](), + timezone: Option[string] = none[string]() + ): string = + + if year.isSome and year.get < 0: + raise newException(ValueError, "year must be non-negative") + if month.isSome and (month.get < 1 or month.get > 12): + raise newException(ValueError, "month must be between 1 and 12") + if day.isSome and (day.get < 1 or day.get > 31): + raise newException(ValueError, "day must be between 1 and 31") + if hour.isSome and (hour.get < 0 or hour.get > 23): + raise newException(ValueError, "hour must be between 0 and 23") + if minute.isSome and (minute.get < 0 or minute.get > 59): + raise newException(ValueError, "minute must be between 0 and 59") + if second.isSome and (second.get < 0 or second.get > 59): + raise newException(ValueError, "second must be between 0 and 59") + if timezone.isSome and + not (timezone.get == "Z" or + (timezone.get.len == 5 and + {'+', '-'}.contains(timezone.get[0]) and + timezone.get[1..^1].allCharsInSet(DIGIT))): + raise newException(ValueError, + "timezone must be 'Z' or an RFC 6350 numeric UTC offset") + + let hasDate = year.isSome or month.isSome or day.isSome + let hasTime = hour.isSome or minute.isSome or second.isSome or timezone.isSome + if not hasDate and not hasTime: + raise newException(ValueError, + "at least one date-and-or-time component must be provided") + if timezone.isSome and second.isNone: + raise newException(ValueError, + "timezone requires a second component for RFC 6350 date-and-or-time values") + + if year.isSome: + result &= padInt(year.get, 4) + elif month.isSome or day.isSome: + result &= "--" + + if month.isSome: + result &= padInt(month.get, 2) + elif day.isSome: + result &= "-" + + if day.isSome: + result &= padInt(day.get, 2) + + if hasTime: + result &= "T" + + if hour.isSome: + result &= padInt(hour.get, 2) + elif minute.isSome or second.isSome or timezone.isSome: + result &= "-" + + if minute.isSome: + result &= padInt(minute.get, 2) + elif second.isSome or timezone.isSome: + result &= "-" + + if second.isSome: + result &= padInt(second.get, 2) + + if timezone.isSome: + result &= timezone.get + proc parseDateAndOrTime[T]( prop: var T, value: string @@ -777,6 +851,60 @@ genTextOrUriPropInitializers(fixedValueTypeProperties --> genUriPropInitializers(fixedValueTypeProperties --> filter(it[1] == vtUri).map(it[0])) +proc newVC4_Bday*( + year: Option[int] = none[int](), + month: Option[int] = none[int](), + day: Option[int] = none[int](), + hour: Option[int] = none[int](), + minute: Option[int] = none[int](), + second: Option[int] = none[int](), + timezone: Option[string] = none[string](), + calscale: Option[string] = none[string](), + altId: Option[string] = none[string](), + group: Option[string] = none[string](), + params: seq[VC_Param] = @[]): VC4_Bday = + + return newVC4_Bday( + value = formatTypedDateAndOrTimeValue( + year = year, + month = month, + day = day, + hour = hour, + minute = minute, + second = second, + timezone = timezone), + calscale = calscale, + altId = altId, + group = group, + params = params) + +proc newVC4_Anniversary*( + year: Option[int] = none[int](), + month: Option[int] = none[int](), + day: Option[int] = none[int](), + hour: Option[int] = none[int](), + minute: Option[int] = none[int](), + second: Option[int] = none[int](), + timezone: Option[string] = none[string](), + calscale: Option[string] = none[string](), + altId: Option[string] = none[string](), + group: Option[string] = none[string](), + params: seq[VC_Param] = @[]): VC4_Anniversary = + + return newVC4_Anniversary( + value = formatTypedDateAndOrTimeValue( + year = year, + month = month, + day = day, + hour = hour, + minute = minute, + second = second, + timezone = timezone), + calscale = calscale, + altId = altId, + group = group, + params = params) + func newVC4_N*( family: seq[string] = @[], given: seq[string] = @[], diff --git a/tests/tvcard4.nim b/tests/tvcard4.nim index 18969c2..99e111f 100644 --- a/tests/tvcard4.nim +++ b/tests/tvcard4.nim @@ -325,6 +325,21 @@ suite "vcard/vcard4": 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(