lib

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

commit cb9bbde65a6dec710e9628c084f3fd56659b1a21
parent 7315ddd1c3d4cff883e5236d9f898884575607c2
Author: triesap <tyson@radroots.org>
Date:   Thu, 11 Jun 2026 17:19:20 -0700

events_codec: add farm file metadata codec

- add NIP-94-style farm file metadata tag encoding and decoding
- keep caption text in event content instead of JSON metadata
- validate workspace addresses, owner document refs, hashes, dimensions, and sources
- export the farm_file event model for codec consumers

Diffstat:
Mcrates/events/src/lib.rs | 1+
Acrates/events_codec/src/farm_file/decode.rs | 295+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/farm_file/encode.rs | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/events_codec/src/farm_file/mod.rs | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/events_codec/src/lib.rs | 1+
5 files changed, 650 insertions(+), 0 deletions(-)

diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs @@ -13,6 +13,7 @@ pub mod coop; pub mod document; pub mod farm; pub mod farm_crdt; +pub mod farm_file; pub mod farm_workspace; pub mod follow; pub mod geochat; diff --git a/crates/events_codec/src/farm_file/decode.rs b/crates/events_codec/src/farm_file/decode.rs @@ -0,0 +1,295 @@ +#[cfg(not(feature = "std"))] +use alloc::{ + string::{String, ToString}, + vec::Vec, +}; + +use radroots_events::{ + RadrootsNostrEvent, + farm_crdt::RadrootsFarmCrdtDocumentKind, + farm_file::{ + KIND_FARM_FILE_METADATA, RadrootsFarmFileDimensions, RadrootsFarmFileMetadata, + RadrootsFarmFileSource, + }, + farm_workspace::KIND_FARM_WORKSPACE_MANIFEST, + tags::{TAG_A, TAG_D, TAG_H, TAG_MIME, TAG_ORIGINAL_SHA256, TAG_SHA256, TAG_URL}, +}; + +use crate::d_tag::validate_d_tag_tag; +use crate::error::EventParseError; +use crate::farm_file::encode::validate_metadata; +use crate::field_helpers::{ + optional_tag_value, parse_address_tag_with_kind, required_tag_value, tag_values, + validate_lowercase_hex_64_tag, validate_non_empty_tag_value, +}; +use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent}; + +const EXPECTED_KIND: &str = "1063"; +const TAG_ALT: &str = "alt"; +const TAG_BLURHASH: &str = "blurhash"; +const TAG_DIMENSIONS: &str = "dim"; +const TAG_FALLBACK: &str = "fallback"; +const TAG_IMAGE: &str = "image"; +const TAG_OWNER_DOCUMENT: &str = "radroots:owner_document"; +const TAG_SIZE: &str = "size"; +const TAG_THUMB: &str = "thumb"; + +pub fn farm_file_metadata_from_event( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsFarmFileMetadata, EventParseError> { + if kind != KIND_FARM_FILE_METADATA { + return Err(EventParseError::InvalidKind { + expected: EXPECTED_KIND, + got: kind, + }); + } + let d_tag = required_single_tag_value(tags, TAG_D)?; + validate_d_tag_tag(&d_tag, TAG_D)?; + let farm_group_id = required_tag_value(tags, TAG_H)?; + let workspace_address = required_tag_value(tags, TAG_A)?; + let workspace = + parse_address_tag_with_kind(&workspace_address, KIND_FARM_WORKSPACE_MANIFEST, TAG_A)?; + let url = required_tag_value(tags, TAG_URL)?; + let mime_type = required_tag_value(tags, TAG_MIME)?; + let sha256 = required_tag_value(tags, TAG_SHA256)?; + validate_lowercase_hex_64_tag(&sha256, TAG_SHA256)?; + let original_sha256 = optional_hash_tag(tags, TAG_ORIGINAL_SHA256)?; + let (owner_document_id, owner_document_kind) = parse_owner_document(tags)?; + let size_bytes = parse_size(tags)?; + let dimensions = parse_dimensions_tag(tags)?; + let blurhash = optional_tag_value(tags, TAG_BLURHASH)?; + let thumb = parse_source_tag(tags, TAG_THUMB)?; + let image = parse_source_tag(tags, TAG_IMAGE)?; + let alt = optional_tag_value(tags, TAG_ALT)?; + let fallbacks = tag_values(tags, TAG_FALLBACK)?; + let caption = if content.is_empty() { + None + } else { + Some(content.to_string()) + }; + + let metadata = RadrootsFarmFileMetadata { + d_tag, + workspace: radroots_events::farm_workspace::RadrootsFarmWorkspaceRef { + pubkey: workspace.pubkey, + d_tag: workspace.d_tag, + }, + farm_group_id, + owner_document_id, + owner_document_kind, + caption, + url, + mime_type, + sha256, + original_sha256, + size_bytes, + dimensions, + blurhash, + thumb, + image, + alt, + fallbacks, + }; + validate_metadata(&metadata).map_err(encode_error_to_parse_error)?; + Ok(metadata) +} + +pub fn data_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, +) -> Result<RadrootsParsedData<RadrootsFarmFileMetadata>, EventParseError> { + let metadata = farm_file_metadata_from_event(kind, &tags, &content)?; + Ok(RadrootsParsedData::new( + id, + author, + published_at, + kind, + metadata, + )) +} + +pub fn parsed_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsParsedEvent<RadrootsFarmFileMetadata>, EventParseError> { + let data = data_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsParsedEvent { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + data, + }) +} + +fn required_single_tag_value( + tags: &[Vec<String>], + key: &'static str, +) -> Result<String, EventParseError> { + let values = tag_values(tags, key)?; + let Some(first) = values.first() else { + return Err(EventParseError::MissingTag(key)); + }; + if values.iter().any(|value| value != first) { + return Err(EventParseError::InvalidTag(key)); + } + Ok(first.clone()) +} + +fn optional_hash_tag( + tags: &[Vec<String>], + key: &'static str, +) -> Result<Option<String>, EventParseError> { + let Some(value) = optional_tag_value(tags, key)? else { + return Ok(None); + }; + validate_lowercase_hex_64_tag(&value, key)?; + Ok(Some(value)) +} + +fn parse_owner_document( + tags: &[Vec<String>], +) -> Result<(String, RadrootsFarmCrdtDocumentKind), EventParseError> { + let tag = tags + .iter() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_OWNER_DOCUMENT)) + .ok_or(EventParseError::MissingTag(TAG_OWNER_DOCUMENT))?; + if tag.len() != 3 { + return Err(EventParseError::InvalidTag(TAG_OWNER_DOCUMENT)); + } + let document_id = tag[1].clone(); + validate_d_tag_tag(&document_id, TAG_OWNER_DOCUMENT)?; + let kind = parse_document_kind_tag(&tag[2])?; + Ok((document_id, kind)) +} + +fn parse_document_kind_tag(value: &str) -> Result<RadrootsFarmCrdtDocumentKind, EventParseError> { + match value { + "FarmTask" => Ok(RadrootsFarmCrdtDocumentKind::FarmTask), + "FarmWorkSession" => Ok(RadrootsFarmCrdtDocumentKind::FarmWorkSession), + "FarmHarvestRecord" => Ok(RadrootsFarmCrdtDocumentKind::FarmHarvestRecord), + "FarmInventoryItem" => Ok(RadrootsFarmCrdtDocumentKind::FarmInventoryItem), + "FarmMediaAsset" => Ok(RadrootsFarmCrdtDocumentKind::FarmMediaAsset), + "FarmObservation" => Ok(RadrootsFarmCrdtDocumentKind::FarmObservation), + _ => Err(EventParseError::InvalidTag(TAG_OWNER_DOCUMENT)), + } +} + +fn parse_size(tags: &[Vec<String>]) -> Result<Option<u64>, EventParseError> { + let Some(value) = optional_tag_value(tags, TAG_SIZE)? else { + return Ok(None); + }; + value + .parse::<u64>() + .map(Some) + .map_err(|err| EventParseError::InvalidNumber(TAG_SIZE, err)) +} + +fn parse_dimensions_tag( + tags: &[Vec<String>], +) -> Result<Option<RadrootsFarmFileDimensions>, EventParseError> { + let Some(value) = optional_tag_value(tags, TAG_DIMENSIONS)? else { + return Ok(None); + }; + Ok(Some(parse_dimensions(&value, TAG_DIMENSIONS)?)) +} + +fn parse_dimensions( + value: &str, + tag: &'static str, +) -> Result<RadrootsFarmFileDimensions, EventParseError> { + let (w, h) = value + .split_once('x') + .ok_or(EventParseError::InvalidTag(tag))?; + let w = w + .parse::<u32>() + .map_err(|_| EventParseError::InvalidTag(tag))?; + let h = h + .parse::<u32>() + .map_err(|_| EventParseError::InvalidTag(tag))?; + if w == 0 || h == 0 { + return Err(EventParseError::InvalidTag(tag)); + } + Ok(RadrootsFarmFileDimensions { w, h }) +} + +fn parse_source_tag( + tags: &[Vec<String>], + key: &'static str, +) -> Result<Option<RadrootsFarmFileSource>, EventParseError> { + let Some(tag) = tags + .iter() + .find(|tag| tag.first().map(|value| value.as_str()) == Some(key)) + else { + return Ok(None); + }; + if tag.len() < 2 || tag.len() > 4 { + return Err(EventParseError::InvalidTag(key)); + } + let url = tag[1].clone(); + validate_non_empty_tag_value(&url, key)?; + let mut mime_type = None; + let mut dimensions = None; + if let Some(value) = tag.get(2) { + validate_non_empty_tag_value(value, key)?; + if value.contains('x') { + dimensions = Some(parse_dimensions(value, key)?); + } else { + mime_type = Some(value.clone()); + } + } + if let Some(value) = tag.get(3) { + validate_non_empty_tag_value(value, key)?; + dimensions = Some(parse_dimensions(value, key)?); + } + Ok(Some(RadrootsFarmFileSource { + url, + mime_type, + dimensions, + })) +} + +fn encode_error_to_parse_error(error: crate::error::EventEncodeError) -> EventParseError { + match error { + crate::error::EventEncodeError::InvalidKind(kind) => EventParseError::InvalidKind { + expected: EXPECTED_KIND, + got: kind, + }, + crate::error::EventEncodeError::EmptyRequiredField(field) + | crate::error::EventEncodeError::InvalidField(field) => match field { + "d_tag" => EventParseError::InvalidTag(TAG_D), + "farm_group_id" => EventParseError::InvalidTag(TAG_H), + "workspace.pubkey" | "workspace.d_tag" => EventParseError::InvalidTag(TAG_A), + "owner_document_id" => EventParseError::InvalidTag(TAG_OWNER_DOCUMENT), + "url" => EventParseError::InvalidTag(TAG_URL), + "mime_type" => EventParseError::InvalidTag(TAG_MIME), + "sha256" => EventParseError::InvalidTag(TAG_SHA256), + "original_sha256" => EventParseError::InvalidTag(TAG_ORIGINAL_SHA256), + field => EventParseError::InvalidTag(field), + }, + crate::error::EventEncodeError::Json => EventParseError::InvalidTag("content"), + } +} diff --git a/crates/events_codec/src/farm_file/encode.rs b/crates/events_codec/src/farm_file/encode.rs @@ -0,0 +1,199 @@ +#[cfg(not(feature = "std"))] +use alloc::{ + format, + string::{String, ToString}, + vec, + vec::Vec, +}; + +use radroots_events::{ + farm_crdt::RadrootsFarmCrdtDocumentKind, + farm_file::{ + KIND_FARM_FILE_METADATA, RadrootsFarmFileDimensions, RadrootsFarmFileMetadata, + RadrootsFarmFileSource, + }, + farm_workspace::KIND_FARM_WORKSPACE_MANIFEST, + tags::{TAG_A, TAG_D, TAG_H, TAG_MIME, TAG_ORIGINAL_SHA256, TAG_SHA256, TAG_URL}, +}; + +use crate::d_tag::validate_d_tag; +use crate::error::EventEncodeError; +use crate::field_helpers::{ + address_string, push_optional_tag, push_tag, push_tag_values, validate_lowercase_hex_64, + validate_non_empty_field, +}; +use crate::wire::WireEventParts; + +const TAG_ALT: &str = "alt"; +const TAG_BLURHASH: &str = "blurhash"; +const TAG_DIMENSIONS: &str = "dim"; +const TAG_FALLBACK: &str = "fallback"; +const TAG_IMAGE: &str = "image"; +const TAG_OWNER_DOCUMENT: &str = "radroots:owner_document"; +const TAG_SIZE: &str = "size"; +const TAG_THUMB: &str = "thumb"; + +pub fn farm_file_metadata_build_tags( + metadata: &RadrootsFarmFileMetadata, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + validate_metadata(metadata)?; + let workspace = address_string( + KIND_FARM_WORKSPACE_MANIFEST, + &metadata.workspace.pubkey, + &metadata.workspace.d_tag, + "workspace", + )?; + let mut tags = Vec::new(); + push_tag(&mut tags, TAG_D, metadata.d_tag.as_str()); + push_tag(&mut tags, TAG_H, metadata.farm_group_id.as_str()); + push_tag(&mut tags, TAG_A, workspace); + push_tag(&mut tags, TAG_URL, metadata.url.as_str()); + push_tag(&mut tags, TAG_MIME, metadata.mime_type.as_str()); + push_tag(&mut tags, TAG_SHA256, metadata.sha256.as_str()); + push_tag_values( + &mut tags, + TAG_OWNER_DOCUMENT, + [ + metadata.owner_document_id.as_str(), + document_kind_tag(metadata.owner_document_kind), + ], + ); + push_optional_tag( + &mut tags, + TAG_ORIGINAL_SHA256, + metadata.original_sha256.as_deref(), + ); + if let Some(size) = metadata.size_bytes { + push_tag(&mut tags, TAG_SIZE, size.to_string()); + } + if let Some(dimensions) = metadata.dimensions { + push_tag(&mut tags, TAG_DIMENSIONS, dimensions_tag(dimensions)); + } + push_optional_tag(&mut tags, TAG_BLURHASH, metadata.blurhash.as_deref()); + push_source_tag(&mut tags, TAG_THUMB, metadata.thumb.as_ref())?; + push_source_tag(&mut tags, TAG_IMAGE, metadata.image.as_ref())?; + push_optional_tag(&mut tags, TAG_ALT, metadata.alt.as_deref()); + for fallback in &metadata.fallbacks { + validate_non_empty_field(fallback, "fallbacks")?; + push_tag(&mut tags, TAG_FALLBACK, fallback.clone()); + } + Ok(tags) +} + +pub fn to_wire_parts( + metadata: &RadrootsFarmFileMetadata, +) -> Result<WireEventParts, EventEncodeError> { + to_wire_parts_with_kind(metadata, KIND_FARM_FILE_METADATA) +} + +pub fn to_wire_parts_with_kind( + metadata: &RadrootsFarmFileMetadata, + kind: u32, +) -> Result<WireEventParts, EventEncodeError> { + if kind != KIND_FARM_FILE_METADATA { + return Err(EventEncodeError::InvalidKind(kind)); + } + let tags = farm_file_metadata_build_tags(metadata)?; + Ok(WireEventParts { + kind, + content: metadata.caption.clone().unwrap_or_default(), + tags, + }) +} + +pub(crate) fn validate_metadata( + metadata: &RadrootsFarmFileMetadata, +) -> Result<(), EventEncodeError> { + validate_d_tag(&metadata.d_tag, "d_tag")?; + validate_non_empty_field(&metadata.farm_group_id, "farm_group_id")?; + validate_non_empty_field(&metadata.workspace.pubkey, "workspace.pubkey")?; + validate_d_tag(&metadata.workspace.d_tag, "workspace.d_tag")?; + validate_d_tag(&metadata.owner_document_id, "owner_document_id")?; + validate_non_empty_field(&metadata.url, "url")?; + validate_non_empty_field(&metadata.mime_type, "mime_type")?; + validate_lowercase_hex_64(&metadata.sha256, "sha256")?; + if let Some(hash) = metadata.original_sha256.as_deref() { + validate_lowercase_hex_64(hash, "original_sha256")?; + } + if let Some(caption) = metadata.caption.as_deref() { + validate_non_empty_field(caption, "caption")?; + } + if let Some(dimensions) = metadata.dimensions { + validate_dimensions(dimensions, "dimensions")?; + } + if let Some(blurhash) = metadata.blurhash.as_deref() { + validate_non_empty_field(blurhash, "blurhash")?; + } + validate_source(metadata.thumb.as_ref(), "thumb")?; + validate_source(metadata.image.as_ref(), "image")?; + if let Some(alt) = metadata.alt.as_deref() { + validate_non_empty_field(alt, "alt")?; + } + for fallback in &metadata.fallbacks { + validate_non_empty_field(fallback, "fallbacks")?; + } + Ok(()) +} + +pub(crate) fn document_kind_tag(kind: RadrootsFarmCrdtDocumentKind) -> &'static str { + match kind { + RadrootsFarmCrdtDocumentKind::FarmTask => "FarmTask", + RadrootsFarmCrdtDocumentKind::FarmWorkSession => "FarmWorkSession", + RadrootsFarmCrdtDocumentKind::FarmHarvestRecord => "FarmHarvestRecord", + RadrootsFarmCrdtDocumentKind::FarmInventoryItem => "FarmInventoryItem", + RadrootsFarmCrdtDocumentKind::FarmMediaAsset => "FarmMediaAsset", + RadrootsFarmCrdtDocumentKind::FarmObservation => "FarmObservation", + } +} + +fn validate_dimensions( + dimensions: RadrootsFarmFileDimensions, + field: &'static str, +) -> Result<(), EventEncodeError> { + if dimensions.w == 0 || dimensions.h == 0 { + Err(EventEncodeError::InvalidField(field)) + } else { + Ok(()) + } +} + +fn validate_source( + source: Option<&RadrootsFarmFileSource>, + field: &'static str, +) -> Result<(), EventEncodeError> { + let Some(source) = source else { + return Ok(()); + }; + validate_non_empty_field(&source.url, field)?; + if let Some(mime_type) = source.mime_type.as_deref() { + validate_non_empty_field(mime_type, field)?; + } + if let Some(dimensions) = source.dimensions { + validate_dimensions(dimensions, field)?; + } + Ok(()) +} + +fn push_source_tag( + tags: &mut Vec<Vec<String>>, + key: &'static str, + source: Option<&RadrootsFarmFileSource>, +) -> Result<(), EventEncodeError> { + let Some(source) = source else { + return Ok(()); + }; + validate_source(Some(source), key)?; + let mut values = vec![source.url.clone()]; + if let Some(mime_type) = source.mime_type.as_deref() { + values.push(mime_type.to_string()); + } + if let Some(dimensions) = source.dimensions { + values.push(dimensions_tag(dimensions)); + } + push_tag_values(tags, key, values); + Ok(()) +} + +fn dimensions_tag(dimensions: RadrootsFarmFileDimensions) -> String { + format!("{}x{}", dimensions.w, dimensions.h) +} diff --git a/crates/events_codec/src/farm_file/mod.rs b/crates/events_codec/src/farm_file/mod.rs @@ -0,0 +1,154 @@ +pub mod decode; +pub mod encode; + +#[cfg(test)] +mod tests { + use radroots_events::{ + farm_crdt::RadrootsFarmCrdtDocumentKind, + farm_file::{ + KIND_FARM_FILE_METADATA, RadrootsFarmFileDimensions, RadrootsFarmFileMetadata, + RadrootsFarmFileSource, + }, + farm_workspace::RadrootsFarmWorkspaceRef, + kinds::KIND_POST, + }; + + use crate::error::{EventEncodeError, EventParseError}; + use crate::farm_file::decode::farm_file_metadata_from_event; + use crate::farm_file::encode::{ + farm_file_metadata_build_tags, to_wire_parts, to_wire_parts_with_kind, + }; + + const FILE_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAQ"; + const WORKSPACE_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; + const OWNER_DOCUMENT_ID: &str = "AAAAAAAAAAAAAAAAAAAAAg"; + const GROUP_ID: &str = "field-group"; + const SHA256: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + #[test] + fn farm_file_metadata_encodes_tags_and_caption_content() { + let metadata = sample_metadata(); + let parts = to_wire_parts(&metadata).expect("file metadata wire parts"); + + assert_eq!(parts.kind, KIND_FARM_FILE_METADATA); + assert_eq!(parts.content, "Tomatoes harvested from Patch Y."); + assert!(parts.tags.contains(&tag("d", FILE_D_TAG))); + assert!(parts.tags.contains(&tag("h", GROUP_ID))); + assert!( + parts + .tags + .contains(&tag("a", "30078:workspace_pubkey:AAAAAAAAAAAAAAAAAAAAAA")) + ); + assert!( + parts + .tags + .contains(&tag("url", "https://media.example.invalid/blob/sha256")) + ); + assert!(parts.tags.contains(&tag("m", "image/jpeg"))); + assert!(parts.tags.contains(&tag("x", SHA256))); + + let decoded = farm_file_metadata_from_event(parts.kind, &parts.tags, &parts.content) + .expect("file metadata decode"); + assert_eq!(decoded, metadata); + } + + #[test] + fn farm_file_metadata_rejects_missing_x_bad_hash_and_missing_url() { + let parts = to_wire_parts(&sample_metadata()).expect("file metadata wire parts"); + let without_x = parts + .tags + .iter() + .filter(|tag| tag.first().map(|value| value.as_str()) != Some("x")) + .cloned() + .collect::<Vec<_>>(); + let missing_x = + farm_file_metadata_from_event(parts.kind, &without_x, &parts.content).unwrap_err(); + assert!(matches!(missing_x, EventParseError::MissingTag("x"))); + + let mut bad_hash = sample_metadata(); + bad_hash.sha256 = "ABC".to_string(); + let hash_err = farm_file_metadata_build_tags(&bad_hash).unwrap_err(); + assert!(matches!(hash_err, EventEncodeError::InvalidField("sha256"))); + + let mut missing_url = sample_metadata(); + missing_url.url.clear(); + let url_err = to_wire_parts(&missing_url).unwrap_err(); + assert!(matches!( + url_err, + EventEncodeError::EmptyRequiredField("url") + )); + } + + #[test] + fn farm_file_metadata_rejects_d_mismatch_and_kind_mismatch() { + let parts = to_wire_parts(&sample_metadata()).expect("file metadata wire parts"); + let mut duplicate_d = parts.tags.clone(); + duplicate_d.push(vec!["d".to_string(), "AAAAAAAAAAAAAAAAAAAAAw".to_string()]); + let mismatch = + farm_file_metadata_from_event(parts.kind, &duplicate_d, &parts.content).unwrap_err(); + assert!(matches!(mismatch, EventParseError::InvalidTag("d"))); + + let wrong_kind = to_wire_parts_with_kind(&sample_metadata(), KIND_POST).unwrap_err(); + assert!(matches!( + wrong_kind, + EventEncodeError::InvalidKind(KIND_POST) + )); + + let decode_wrong_kind = + farm_file_metadata_from_event(KIND_POST, &parts.tags, &parts.content).unwrap_err(); + assert!(matches!( + decode_wrong_kind, + EventParseError::InvalidKind { + expected: "1063", + got: KIND_POST + } + )); + } + + #[test] + fn farm_file_metadata_decodes_empty_content_as_absent_caption() { + let mut metadata = sample_metadata(); + metadata.caption = None; + let parts = to_wire_parts(&metadata).expect("file metadata wire parts"); + + assert_eq!(parts.content, ""); + let decoded = + farm_file_metadata_from_event(parts.kind, &parts.tags, "").expect("file decode"); + assert_eq!(decoded.caption, None); + } + + fn sample_metadata() -> RadrootsFarmFileMetadata { + RadrootsFarmFileMetadata { + d_tag: FILE_D_TAG.to_string(), + workspace: RadrootsFarmWorkspaceRef { + pubkey: "workspace_pubkey".to_string(), + d_tag: WORKSPACE_D_TAG.to_string(), + }, + farm_group_id: GROUP_ID.to_string(), + owner_document_id: OWNER_DOCUMENT_ID.to_string(), + owner_document_kind: RadrootsFarmCrdtDocumentKind::FarmTask, + caption: Some("Tomatoes harvested from Patch Y.".to_string()), + url: "https://media.example.invalid/blob/sha256".to_string(), + mime_type: "image/jpeg".to_string(), + sha256: SHA256.to_string(), + original_sha256: Some( + "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string(), + ), + size_bytes: Some(123_456), + dimensions: Some(RadrootsFarmFileDimensions { w: 1600, h: 1200 }), + blurhash: Some("LEHV6nWB2yk8pyo0adR*.7kCMdnj".to_string()), + thumb: Some(RadrootsFarmFileSource { + url: "https://media.example.invalid/thumb/sha256".to_string(), + mime_type: Some("image/jpeg".to_string()), + dimensions: Some(RadrootsFarmFileDimensions { w: 320, h: 240 }), + }), + image: None, + alt: Some("Harvested tomatoes in a crate".to_string()), + fallbacks: vec!["https://fallback.example.invalid/blob/sha256".to_string()], + } + } + + fn tag(key: &str, value: &str) -> Vec<String> { + vec![key.to_string(), value.to_string()] + } +} diff --git a/crates/events_codec/src/lib.rs b/crates/events_codec/src/lib.rs @@ -19,6 +19,7 @@ pub mod coop; pub mod document; pub mod farm; pub mod farm_crdt; +pub mod farm_file; pub mod farm_workspace; pub mod follow; pub mod geochat;