diff --git a/src/lib.rs b/src/lib.rs index ef29d43..2527e40 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,9 @@ use fluent_uri::Uri; pub mod at_uri; pub mod did; +pub mod record; + +pub use record::collect_links; #[derive(Debug, PartialEq)] pub enum Link { @@ -10,12 +13,22 @@ pub enum Link { Did(String), } +impl Link { + pub fn into_string(self) -> String { + match self { + Link::AtUri(s) => s, + Link::Uri(s) => s, + Link::Did(s) => s, + } + } +} + // normalizing is a bit opinionated but eh pub fn parse_uri(s: &str) -> Option { Uri::parse(s).map(|u| u.normalize().into_string()).ok() } -pub fn parse_any(s: &str) -> Option { +pub fn parse_any_link(s: &str) -> Option { at_uri::parse_at_uri(s).map(Link::AtUri).or_else(|| { did::parse_did(s) .map(Link::Did) @@ -49,19 +62,21 @@ mod tests { #[test] fn test_any_parse() { assert_eq!( - parse_any("https://example.com"), + parse_any_link("https://example.com"), Some(Link::Uri("https://example.com".into())) ); assert_eq!( - parse_any("at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26"), + parse_any_link( + "at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26" + ), Some(Link::AtUri( "at://did:plc:44ybard66vv44zksje25o7dz/app.bsky.feed.post/3jwdwj2ctlk26".into() )), ); assert_eq!( - parse_any("did:plc:44ybard66vv44zksje25o7dz"), + parse_any_link("did:plc:44ybard66vv44zksje25o7dz"), Some(Link::Did("did:plc:44ybard66vv44zksje25o7dz".into())) ) } diff --git a/src/record.rs b/src/record.rs new file mode 100644 index 0000000..89fcd35 --- /dev/null +++ b/src/record.rs @@ -0,0 +1,137 @@ +use tinyjson::JsonValue; + +use crate::parse_any_link; + +pub fn walk_record(path: &str, v: &JsonValue, found: &mut Vec<(String, String)>) { + match v { + JsonValue::Object(o) => { + for (key, child) in o { + walk_record(&format!("{path}.{key}"), child, found) + } + } + JsonValue::Array(a) => { + let p = format!("{path}[]"); + for child in a { + walk_record(&p, child, found) + } + } + JsonValue::String(s) => { + if let Some(link) = parse_any_link(s) { + found.push((path.to_string(), link.into_string())); + } + } + _ => {} + } +} + +pub fn collect_links(v: JsonValue) -> Vec<(String, String)> { + let mut found = vec![]; + walk_record("", &v, &mut found); + found +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_collect_links() { + let rec = r#"{"a": "https://example.com", "b": "not a link"}"#; + let json = collect_links(rec.parse().unwrap()); + assert_eq!(json, vec![(".a".into(), "https://example.com".into())]); + } + + #[test] + fn test_bsky_feed_post_record_reply() { + let rec = r#"{ + "$type": "app.bsky.feed.post", + "createdAt": "2025-01-08T20:52:43.041Z", + "langs": [ + "en" + ], + "reply": { + "parent": { + "cid": "bafyreifk3bwnmulk37ezrarg4ouheqnhgucypynftqafl4limssogvzk6i", + "uri": "at://did:plc:b3rzzkblqsxhr3dgcueymkqe/app.bsky.feed.post/3lf6yc4drhk2f" + }, + "root": { + "cid": "bafyreifk3bwnmulk37ezrarg4ouheqnhgucypynftqafl4limssogvzk6i", + "uri": "at://did:plc:b3rzzkblqsxhr3dgcueymkqe/app.bsky.feed.post/3lf6yc4drhk2f" + } + }, + "text": "Yup!" + }"#; + let mut json = collect_links(rec.parse().unwrap()); + json.sort(); + assert_eq!( + json, + vec![ + ( + ".reply.parent.uri".into(), + "at://did:plc:b3rzzkblqsxhr3dgcueymkqe/app.bsky.feed.post/3lf6yc4drhk2f".into() + ), + ( + ".reply.root.uri".into(), + "at://did:plc:b3rzzkblqsxhr3dgcueymkqe/app.bsky.feed.post/3lf6yc4drhk2f".into() + ), + ] + ) + } + + #[test] + fn test_bsky_feed_post_record_embed() { + let rec = r#"{ + "$type": "app.bsky.feed.post", + "createdAt": "2025-01-08T20:52:39.539Z", + "embed": { + "$type": "app.bsky.embed.external", + "external": { + "description": "YouTube video by More Perfect Union", + "thumb": { + "$type": "blob", + "ref": { + "$link": "bafkreifxuvkbqksq5usi4cryex37o4absjexuouvgenlb62ojsx443b2tm" + }, + "mimeType": "image/jpeg", + "size": 477460 + }, + "title": "Corporations & Wealthy Elites Are Coopting Our Government. Who Can Stop Them?", + "uri": "https://youtu.be/oKXm4szEP1Q?si=_0n_uPu4qNKokMnq" + } + }, + "facets": [ + { + "features": [ + { + "$type": "app.bsky.richtext.facet#link", + "uri": "https://youtu.be/oKXm4szEP1Q?si=_0n_uPu4qNKokMnq" + } + ], + "index": { + "byteEnd": 24, + "byteStart": 0 + } + } + ], + "langs": [ + "en" + ], + "text": "youtu.be/oKXm4szEP1Q?..." + }"#; + let mut json = collect_links(rec.parse().unwrap()); + json.sort(); + assert_eq!( + json, + vec![ + ( + ".embed.external.uri".into(), + "https://youtu.be/oKXm4szEP1Q?si=_0n_uPu4qNKokMnq".into() + ), + ( + ".facets[].features[].uri".into(), + "https://youtu.be/oKXm4szEP1Q?si=_0n_uPu4qNKokMnq".into() + ), + ] + ) + } +}