commit 3c9c22168ec41dc26315645d4b1f1b7611d12128
parent 72df2e9646a144290af1c538ed033adab9418089
Author: triesap <tyson@radroots.org>
Date: Fri, 12 Jun 2026 16:09:52 -0700
events_codec: support calendar descriptions
- add NIP-52 description content fields to calendar event models
- require uppercase D date anchors on calendar time events
- update calendar encode and decode tests for content roundtrips
- align social conformance vectors with the hardened calendar contract
Diffstat:
9 files changed, 152 insertions(+), 25 deletions(-)
diff --git a/crates/events/src/calendar.rs b/crates/events/src/calendar.rs
@@ -16,6 +16,11 @@ pub struct RadrootsCalendar {
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
+ pub description: Option<String>,
+ #[cfg_attr(
+ feature = "serde",
+ serde(default, skip_serializing_if = "Option::is_none")
+ )]
pub summary: Option<String>,
#[cfg_attr(
feature = "serde",
@@ -34,6 +39,11 @@ pub struct RadrootsCalendarDateEvent {
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
+ pub description: Option<String>,
+ #[cfg_attr(
+ feature = "serde",
+ serde(default, skip_serializing_if = "Option::is_none")
+ )]
pub end: Option<String>,
#[cfg_attr(
feature = "serde",
@@ -70,6 +80,16 @@ pub struct RadrootsCalendarTimeEvent {
pub start: u64,
#[cfg_attr(
feature = "serde",
+ serde(default, skip_serializing_if = "Vec::is_empty")
+ )]
+ pub dates: Vec<RadrootsCalendarDateValue>,
+ #[cfg_attr(
+ feature = "serde",
+ serde(default, skip_serializing_if = "Option::is_none")
+ )]
+ pub description: Option<String>,
+ #[cfg_attr(
+ feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub end: Option<u64>,
@@ -143,6 +163,7 @@ mod tests {
d_tag: "market-day".to_string(),
title: "market day".to_string(),
start: "2026-06-20".to_string(),
+ description: Some("Farm stand pickup window.".to_string()),
end: None,
days: Some(vec![RadrootsCalendarDateValue {
value: "2026-06-20".to_string(),
@@ -167,6 +188,10 @@ mod tests {
d_tag: "wash-pack".to_string(),
title: "wash pack shift".to_string(),
start: 1_781_895_600,
+ dates: vec![RadrootsCalendarDateValue {
+ value: "2026-06-20".to_string(),
+ }],
+ description: Some("Pack CSA shares before pickup.".to_string()),
end: Some(1_781_899_200),
start_tzid: Some("America/Vancouver".to_string()),
end_tzid: Some("America/Vancouver".to_string()),
@@ -197,6 +222,7 @@ mod tests {
event_kind: Some(31923),
relays: None,
}],
+ description: Some("Shared farm operations schedule.".to_string()),
summary: None,
image: None,
};
diff --git a/crates/events_codec/src/calendar/decode.rs b/crates/events_codec/src/calendar/decode.rs
@@ -47,9 +47,6 @@ pub fn calendar_date_event_from_event(
got: kind,
});
}
- if !content.is_empty() {
- return Err(EventParseError::InvalidJson("content"));
- }
let d_tag = required_tag_value(tags, TAG_D)?;
validate_d_tag_tag(&d_tag, TAG_D)?;
let title = required_tag_value(tags, TAG_TITLE)?;
@@ -73,6 +70,7 @@ pub fn calendar_date_event_from_event(
d_tag,
title,
start,
+ description: optional_content(content),
end,
days: non_empty_vec(days),
location: location_from_tags(tags),
@@ -93,9 +91,6 @@ pub fn calendar_time_event_from_event(
got: kind,
});
}
- if !content.is_empty() {
- return Err(EventParseError::InvalidJson("content"));
- }
let d_tag = required_tag_value(tags, TAG_D)?;
validate_d_tag_tag(&d_tag, TAG_D)?;
let title = required_tag_value(tags, TAG_TITLE)?;
@@ -103,10 +98,22 @@ pub fn calendar_time_event_from_event(
let end = parse_optional_u64(tags, TAG_END)?;
validate_end_after_start(start, end, TAG_END)
.map_err(|_| EventParseError::InvalidTag(TAG_END))?;
+ let dates = tag_values(tags, TAG_D_DAY)?
+ .into_iter()
+ .map(|value| {
+ validate_date_tag(&value, TAG_D_DAY)?;
+ Ok(RadrootsCalendarDateValue { value })
+ })
+ .collect::<Result<Vec<_>, EventParseError>>()?;
+ if dates.is_empty() {
+ return Err(EventParseError::MissingTag(TAG_D_DAY));
+ }
Ok(RadrootsCalendarTimeEvent {
d_tag,
title,
start,
+ dates,
+ description: optional_content(content),
end,
start_tzid: optional_tag_value(tags, TAG_START_TZID)?,
end_tzid: optional_tag_value(tags, TAG_END_TZID)?,
@@ -128,9 +135,6 @@ pub fn calendar_from_event(
got: kind,
});
}
- if !content.is_empty() {
- return Err(EventParseError::InvalidJson("content"));
- }
let d_tag = required_tag_value(tags, TAG_D)?;
validate_d_tag_tag(&d_tag, TAG_D)?;
let title = required_tag_value(tags, TAG_TITLE)?;
@@ -142,6 +146,7 @@ pub fn calendar_from_event(
d_tag,
title,
events,
+ description: optional_content(content),
summary: optional_tag_value(tags, TAG_SUMMARY)?,
image: optional_tag_value(tags, TAG_IMAGE)?,
})
@@ -407,6 +412,14 @@ fn non_empty_vec<T>(values: Vec<T>) -> Option<Vec<T>> {
}
}
+fn optional_content(content: &str) -> Option<String> {
+ if content.is_empty() {
+ None
+ } else {
+ Some(content.to_string())
+ }
+}
+
fn calendar_event_targets_from_tags(
tags: &[Vec<String>],
) -> Result<Vec<RadrootsSocialTarget>, EventParseError> {
diff --git a/crates/events_codec/src/calendar/encode.rs b/crates/events_codec/src/calendar/encode.rs
@@ -28,7 +28,7 @@ use crate::social_helpers::{
push_location_tags, push_participants, validate_date, validate_date_end_after_start,
validate_end_after_start,
};
-use crate::wire::{WireEventParts, empty_content};
+use crate::wire::WireEventParts;
pub fn calendar_date_event_build_tags(
event: &RadrootsCalendarDateEvent,
@@ -62,6 +62,10 @@ pub fn calendar_time_event_build_tags(
push_tag(&mut tags, TAG_D, event.d_tag.as_str());
push_tag(&mut tags, TAG_TITLE, event.title.as_str());
push_tag(&mut tags, TAG_START, event.start.to_string());
+ for date in &event.dates {
+ validate_date(&date.value, "dates")?;
+ push_tag(&mut tags, TAG_D_DAY, date.value.as_str());
+ }
if let Some(end) = event.end {
push_tag(&mut tags, TAG_END, end.to_string());
}
@@ -153,7 +157,7 @@ pub fn date_to_wire_parts_with_kind(
}
Ok(WireEventParts {
kind,
- content: empty_content(),
+ content: event.description.clone().unwrap_or_default(),
tags: calendar_date_event_build_tags(event)?,
})
}
@@ -167,7 +171,7 @@ pub fn time_to_wire_parts_with_kind(
}
Ok(WireEventParts {
kind,
- content: empty_content(),
+ content: event.description.clone().unwrap_or_default(),
tags: calendar_time_event_build_tags(event)?,
})
}
@@ -181,7 +185,7 @@ pub fn calendar_to_wire_parts_with_kind(
}
Ok(WireEventParts {
kind,
- content: empty_content(),
+ content: calendar.description.clone().unwrap_or_default(),
tags: calendar_collection_build_tags(calendar)?,
})
}
@@ -215,6 +219,12 @@ fn validate_time_event(event: &RadrootsCalendarTimeEvent) -> Result<(), EventEnc
validate_d_tag(&event.d_tag, "d_tag")?;
validate_non_empty_field(&event.title, "title")?;
validate_end_after_start(event.start, event.end, "end")?;
+ if event.dates.is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("dates"));
+ }
+ for date in &event.dates {
+ validate_date(&date.value, "dates")?;
+ }
Ok(())
}
diff --git a/crates/events_codec/tests/calendar.rs b/crates/events_codec/tests/calendar.rs
@@ -47,6 +47,7 @@ fn sample_date_event() -> RadrootsCalendarDateEvent {
d_tag: VALID_D_TAG.to_string(),
title: "CSA pickup".to_string(),
start: "2026-06-20".to_string(),
+ description: Some("Bring clean bins to the farm stand.".to_string()),
end: Some("2026-06-21".to_string()),
days: Some(vec![RadrootsCalendarDateValue {
value: "2026-06-20".to_string(),
@@ -70,6 +71,10 @@ fn sample_time_event() -> RadrootsCalendarTimeEvent {
d_tag: VALID_D_TAG.to_string(),
title: "Wash pack shift".to_string(),
start: 1_781_895_600,
+ dates: vec![RadrootsCalendarDateValue {
+ value: "2026-06-20".to_string(),
+ }],
+ description: Some("Prepare CSA bins before pickup.".to_string()),
end: Some(1_781_899_200),
start_tzid: Some("America/Vancouver".to_string()),
end_tzid: Some("America/Vancouver".to_string()),
@@ -97,6 +102,7 @@ fn sample_calendar_collection() -> RadrootsCalendar {
event_kind: Some(KIND_CALENDAR_TIME_EVENT),
relays: Some(vec!["wss://relay.example.test".to_string()]),
}],
+ description: Some("Shared schedule for farm operations.".to_string()),
summary: Some("CSA and harvest schedule".to_string()),
image: Some("https://media.example.test/calendar.jpg".to_string()),
}
@@ -136,7 +142,7 @@ fn calendar_date_event_to_wire_parts_roundtrips_tags() {
let parts = date_to_wire_parts(&event).unwrap();
assert_eq!(parts.kind, KIND_CALENDAR_DATE_EVENT);
- assert!(parts.content.is_empty());
+ assert_eq!(parts.content, "Bring clean bins to the farm stand.");
assert!(has_tag(&parts.tags, TAG_D, VALID_D_TAG));
assert!(has_tag(&parts.tags, TAG_TITLE, "CSA pickup"));
assert!(has_tag(&parts.tags, TAG_START, "2026-06-20"));
@@ -155,6 +161,10 @@ fn calendar_date_event_to_wire_parts_roundtrips_tags() {
let decoded = calendar_date_event_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
assert_eq!(decoded.d_tag, VALID_D_TAG);
assert_eq!(decoded.title, "CSA pickup");
+ assert_eq!(
+ decoded.description.as_deref(),
+ Some("Bring clean bins to the farm stand.")
+ );
assert_eq!(decoded.start, "2026-06-20");
assert_eq!(decoded.end.as_deref(), Some("2026-06-21"));
assert_eq!(decoded.days.as_ref().map(Vec::len), Some(1));
@@ -174,10 +184,11 @@ fn calendar_time_event_to_wire_parts_roundtrips_tags() {
let parts = time_to_wire_parts(&event).unwrap();
assert_eq!(parts.kind, KIND_CALENDAR_TIME_EVENT);
- assert!(parts.content.is_empty());
+ assert_eq!(parts.content, "Prepare CSA bins before pickup.");
assert!(has_tag(&parts.tags, TAG_D, VALID_D_TAG));
assert!(has_tag(&parts.tags, TAG_TITLE, "Wash pack shift"));
assert!(has_tag(&parts.tags, TAG_START, "1781895600"));
+ assert!(has_tag(&parts.tags, TAG_D_DAY, "2026-06-20"));
assert!(has_tag(&parts.tags, TAG_END, "1781899200"));
assert!(has_tag(&parts.tags, TAG_START_TZID, "America/Vancouver"));
assert!(has_tag(&parts.tags, TAG_END_TZID, "America/Vancouver"));
@@ -187,7 +198,12 @@ fn calendar_time_event_to_wire_parts_roundtrips_tags() {
let decoded = calendar_time_event_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
assert_eq!(decoded.d_tag, VALID_D_TAG);
assert_eq!(decoded.title, "Wash pack shift");
+ assert_eq!(
+ decoded.description.as_deref(),
+ Some("Prepare CSA bins before pickup.")
+ );
assert_eq!(decoded.start, 1_781_895_600);
+ assert_eq!(decoded.dates.len(), 1);
assert_eq!(decoded.end, Some(1_781_899_200));
assert_eq!(decoded.start_tzid.as_deref(), Some("America/Vancouver"));
assert_eq!(decoded.participants.as_ref().map(Vec::len), Some(1));
@@ -199,7 +215,7 @@ fn calendar_collection_to_wire_parts_roundtrips_event_addresses() {
let parts = calendar_to_wire_parts(&calendar).unwrap();
assert_eq!(parts.kind, KIND_CALENDAR);
- assert!(parts.content.is_empty());
+ assert_eq!(parts.content, "Shared schedule for farm operations.");
assert!(has_tag(&parts.tags, TAG_D, VALID_D_TAG));
assert!(has_tag(&parts.tags, TAG_TITLE, "Farm calendar"));
assert!(has_tag(
@@ -211,6 +227,10 @@ fn calendar_collection_to_wire_parts_roundtrips_event_addresses() {
let decoded = calendar_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
assert_eq!(decoded.d_tag, VALID_D_TAG);
assert_eq!(decoded.title, "Farm calendar");
+ assert_eq!(
+ decoded.description.as_deref(),
+ Some("Shared schedule for farm operations.")
+ );
assert_eq!(decoded.events.len(), 1);
assert!(matches!(
decoded.events[0],
@@ -248,7 +268,7 @@ fn calendar_rsvp_to_wire_parts_roundtrips_status_event_id_and_participants() {
}
#[test]
-fn calendar_codecs_reject_wrong_kind_invalid_dates_and_nonempty_content() {
+fn calendar_codecs_reject_wrong_kind_invalid_dates_and_missing_time_dates() {
assert!(matches!(
date_to_wire_parts_with_kind(&sample_date_event(), KIND_POST),
Err(EventEncodeError::InvalidKind(KIND_POST))
@@ -272,12 +292,17 @@ fn calendar_codecs_reject_wrong_kind_invalid_dates_and_nonempty_content() {
Err(EventEncodeError::InvalidField("end"))
));
- let tags = calendar_date_event_build_tags(&sample_date_event()).unwrap();
+ let mut event = sample_time_event();
+ event.dates.clear();
assert!(matches!(
- calendar_date_event_from_event(KIND_CALENDAR_DATE_EVENT, &tags, "body"),
- Err(EventParseError::InvalidJson("content"))
+ calendar_time_event_build_tags(&event),
+ Err(EventEncodeError::EmptyRequiredField("dates"))
));
+ let tags = calendar_date_event_build_tags(&sample_date_event()).unwrap();
+ let decoded = calendar_date_event_from_event(KIND_CALENDAR_DATE_EVENT, &tags, "body").unwrap();
+ assert_eq!(decoded.description.as_deref(), Some("body"));
+
let mut tags = calendar_date_event_build_tags(&sample_date_event()).unwrap();
let start = tags
.iter_mut()
@@ -289,6 +314,13 @@ fn calendar_codecs_reject_wrong_kind_invalid_dates_and_nonempty_content() {
Err(EventParseError::InvalidTag(TAG_START))
));
+ let mut tags = calendar_time_event_build_tags(&sample_time_event()).unwrap();
+ tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_D_DAY));
+ assert!(matches!(
+ calendar_time_event_from_event(KIND_CALENDAR_TIME_EVENT, &tags, ""),
+ Err(EventParseError::MissingTag(TAG_D_DAY))
+ ));
+
let err = calendar_time_event_from_event(KIND_POST, &tags, "").unwrap_err();
assert!(matches!(
err,
diff --git a/crates/events_codec_wasm/src/lib.rs b/crates/events_codec_wasm/src/lib.rs
@@ -590,6 +590,7 @@ mod tests {
d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_string(),
title: "market day".to_string(),
start: "2026-06-20".to_string(),
+ description: Some("Farm stand pickup window.".to_string()),
end: Some("2026-06-21".to_string()),
days: Some(vec![RadrootsCalendarDateValue {
value: "2026-06-20".to_string(),
@@ -610,6 +611,10 @@ mod tests {
d_tag: "AAAAAAAAAAAAAAAAAAAA-A".to_string(),
title: "wash pack shift".to_string(),
start: 1_781_895_600,
+ dates: vec![RadrootsCalendarDateValue {
+ value: "2026-06-20".to_string(),
+ }],
+ description: Some("Prepare CSA bins before pickup.".to_string()),
end: Some(1_781_899_200),
start_tzid: Some("America/Vancouver".to_string()),
end_tzid: Some("America/Vancouver".to_string()),
@@ -625,6 +630,7 @@ mod tests {
d_tag: "AAAAAAAAAAAAAAAAAAAA_A".to_string(),
title: "farm calendar".to_string(),
events: vec![address_target(31923, "AAAAAAAAAAAAAAAAAAAA-A")],
+ description: Some("Shared schedule for farm operations.".to_string()),
summary: Some("field schedule".to_string()),
image: None,
}
diff --git a/spec/conformance/vectors/social/mvp.v1.json b/spec/conformance/vectors/social/mvp.v1.json
@@ -343,6 +343,7 @@
"d_tag": "AAAAAAAAAAAAAAAAAAAAAw",
"title": "market day",
"start": "2026-06-20",
+ "description": "Farm stand pickup window.",
"end": "2026-06-21",
"days": [
{
@@ -358,8 +359,9 @@
"title",
"start",
"end",
- "d"
- ]
+ "D"
+ ],
+ "content": "Farm stand pickup window."
}
},
{
@@ -386,6 +388,12 @@
"d_tag": "AAAAAAAAAAAAAAAAAAAA-A",
"title": "wash pack shift",
"start": 1781895600,
+ "dates": [
+ {
+ "value": "2026-06-20"
+ }
+ ],
+ "description": "Prepare CSA bins before pickup.",
"end": 1781899200,
"start_tzid": "America/Vancouver"
}
@@ -396,9 +404,11 @@
"d",
"title",
"start",
+ "D",
"end",
"start_tzid"
- ]
+ ],
+ "content": "Prepare CSA bins before pickup."
}
},
{
@@ -409,6 +419,11 @@
"d_tag": "AAAAAAAAAAAAAAAAAAAA-A",
"title": "wash pack shift",
"start": 1781899200,
+ "dates": [
+ {
+ "value": "2026-06-20"
+ }
+ ],
"end": 1781895600
}
},
@@ -417,6 +432,22 @@
"error_class": "encode_error",
"field": "end"
}
+ },
+ {
+ "id": "social_calendar_time_missing_date_invalid_018",
+ "kind": "social.calendar_time_event.build_tags.invalid",
+ "input": {
+ "event": {
+ "d_tag": "AAAAAAAAAAAAAAAAAAAA-A",
+ "title": "wash pack shift",
+ "start": 1781895600
+ }
+ },
+ "expected": {
+ "result": "error",
+ "error_class": "encode_error",
+ "field": "dates"
+ }
}
]
}
diff --git a/spec/conformance/vectors/social/production.v1.json b/spec/conformance/vectors/social/production.v1.json
@@ -101,6 +101,7 @@
"calendar": {
"d_tag": "AAAAAAAAAAAAAAAAAAAA_A",
"title": "farm calendar",
+ "description": "Shared schedule for farm operations.",
"events": [
{
"kind": "address",
@@ -120,7 +121,8 @@
"d",
"title",
"a"
- ]
+ ],
+ "content": "Shared schedule for farm operations."
}
},
{
diff --git a/spec/conformance/vectors/social/upgraded_boundaries.v1.json b/spec/conformance/vectors/social/upgraded_boundaries.v1.json
@@ -112,7 +112,7 @@
"input": {
"event": {
"kind": 31924,
- "content": "",
+ "content": "Shared schedule for farm operations.",
"tags": [
[
"d",
diff --git a/spec/social-events.md b/spec/social-events.md
@@ -77,6 +77,13 @@ though both use kind `1063`. The public generic model must cover the current sim
including URL, MIME type, SHA-256 hash, original hash, size, dimensions, blurhash, thumbnail, image,
summary, alt text, fallback, `magnet`, `i`, and `service`.
+`RadrootsCalendarDateEvent`, `RadrootsCalendarTimeEvent`, and `RadrootsCalendar` use NIP-52
+description content. Optional `description` data is encoded as event content and empty content
+decodes to no description. Calendar date events use lowercase `d` for the replaceable identifier and
+optional uppercase `D` tags for covered all-day dates. Calendar time events require at least one
+uppercase `D` tag so timestamped events retain a deterministic calendar-date anchor across codecs and
+language exports.
+
`RadrootsListingDraft` and `RadrootsRelayList` are not separate model types in the target contract.
Listing draft kind `30403` is represented through `RadrootsListing`, and NIP-51 standard and
list-set entries, including NIP-65 relay metadata kind `10002`, are represented through