lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

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:
Mcrates/events/src/calendar.rs | 26++++++++++++++++++++++++++
Mcrates/events_codec/src/calendar/decode.rs | 31++++++++++++++++++++++---------
Mcrates/events_codec/src/calendar/encode.rs | 18++++++++++++++----
Mcrates/events_codec/tests/calendar.rs | 46+++++++++++++++++++++++++++++++++++++++-------
Mcrates/events_codec_wasm/src/lib.rs | 6++++++
Mspec/conformance/vectors/social/mvp.v1.json | 37++++++++++++++++++++++++++++++++++---
Mspec/conformance/vectors/social/production.v1.json | 4+++-
Mspec/conformance/vectors/social/upgraded_boundaries.v1.json | 2+-
Mspec/social-events.md | 7+++++++
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