at main 237 lines 7.9 kB view raw
1//! Weaver common library - thin wrapper around jacquard with notebook-specific conveniences 2 3pub mod agent; 4#[cfg(feature = "cache")] 5pub mod cache; 6pub mod constellation; 7pub mod domain_encoding; 8pub mod error; 9#[cfg(feature = "perf")] 10pub mod perf; 11pub mod resolve; 12#[cfg(feature = "telemetry")] 13pub mod telemetry; 14pub mod transport; 15pub mod worker_rt; 16 17// Re-export jacquard for convenience 18pub use agent::{SessionPeer, WeaverExt}; 19pub use domain_encoding::{decode_publication_subdomain, encode_publication_subdomain}; 20pub use error::WeaverError; 21 22// Re-export blake3 for topic hashing 23pub use blake3; 24pub use resolve::{EntryIndex, ExtractedRef, RefCollector, ResolvedContent, ResolvedEntry}; 25 26pub use jacquard; 27use jacquard::CowStr; 28use jacquard::client::{Agent, AgentSession}; 29use jacquard::prelude::*; 30use jacquard::types::ident::AtIdentifier; 31use jacquard::types::string::{AtUri, Cid, Did, Handle}; 32use jacquard::types::tid::Ticker; 33pub use resolve::collect_refs_from_markdown; 34use std::sync::LazyLock; 35use tokio::sync::Mutex; 36use weaver_api::com_atproto::repo::strong_ref::StrongRef; 37 38static W_TICKER: LazyLock<Mutex<Ticker>> = LazyLock::new(|| Mutex::new(Ticker::new())); 39 40/// Result of publishing a notebook 41#[derive(Debug, Clone)] 42pub struct PublishResult<'a> { 43 /// AT-URI of the published book 44 pub uri: AtUri<'a>, 45 /// CID of the book record 46 pub cid: Cid<'a>, 47 /// URIs of published entries 48 pub entries: Vec<StrongRef<'a>>, 49} 50 51pub fn mcow_to_cow(cow: CowStr<'_>) -> std::borrow::Cow<'_, str> { 52 match cow { 53 CowStr::Borrowed(s) => std::borrow::Cow::Borrowed(s), 54 CowStr::Owned(s) => std::borrow::Cow::Owned(s.to_string()), 55 } 56} 57 58pub fn cow_to_mcow(cow: std::borrow::Cow<'_, str>) -> CowStr<'_> { 59 match cow { 60 std::borrow::Cow::Borrowed(s) => CowStr::Borrowed(s), 61 std::borrow::Cow::Owned(s) => CowStr::Owned(s.into()), 62 } 63} 64 65pub fn mdcow_to_cow(cow: markdown_weaver::CowStr<'_>) -> std::borrow::Cow<'_, str> { 66 match cow { 67 markdown_weaver::CowStr::Borrowed(s) => std::borrow::Cow::Borrowed(s), 68 markdown_weaver::CowStr::Boxed(s) => std::borrow::Cow::Owned(s.to_string()), 69 markdown_weaver::CowStr::Inlined(s) => std::borrow::Cow::Owned(s.as_ref().to_owned()), 70 } 71} 72 73/// Utility: Generate CDN URL for avatar blob 74pub fn avatar_cdn_url(did: &Did, cid: &Cid) -> String { 75 format!( 76 "https://cdn.bsky.app/img/avatar/plain/{}/{}", 77 did.as_str(), 78 cid 79 ) 80} 81 82/// Utility: Generate PDS URL for blob retrieval 83pub fn blob_url(did: &Did, pds: &str, cid: &Cid) -> String { 84 format!( 85 "https://{}/xrpc/com.atproto.repo.getBlob?did={}&cid={}", 86 pds, 87 did.as_str(), 88 cid 89 ) 90} 91 92pub fn match_identifier(maybe_identifier: &str) -> Option<&str> { 93 if jacquard::types::string::AtIdentifier::new(maybe_identifier).is_ok() { 94 Some(maybe_identifier) 95 } else { 96 None 97 } 98} 99 100pub fn match_nsid(maybe_nsid: &str) -> Option<&str> { 101 if jacquard::types::string::Nsid::new(maybe_nsid).is_ok() { 102 Some(maybe_nsid) 103 } else { 104 None 105 } 106} 107 108/// Convert an ATURI to a HTTP URL 109/// Currently has some failure modes and should restrict the NSIDs to a known subset 110pub fn aturi_to_http<'s>(aturi: &'s str, appview: &'s str) -> Option<markdown_weaver::CowStr<'s>> { 111 use markdown_weaver::CowStr; 112 113 if aturi.starts_with("at://") { 114 let rest = aturi.strip_prefix("at://").unwrap(); 115 let mut split = rest.splitn(2, '/'); 116 let maybe_identifier = split.next()?; 117 let maybe_nsid = split.next()?; 118 let maybe_rkey = split.next()?; 119 120 // https://atproto.com/specs/handle#handle-identifier-syntax 121 let identifier = match_identifier(maybe_identifier)?; 122 123 let nsid = if let Some(nsid) = match_nsid(maybe_nsid) { 124 // Last part of the nsid is generally the middle component of the URL 125 // TODO: check for bsky ones specifically, because those are the ones where this is valid 126 nsid.rsplitn(1, '.').next()? 127 } else { 128 return None; 129 }; 130 Some(CowStr::Boxed( 131 format!( 132 "https://{}/profile/{}/{}/{}", 133 appview, identifier, nsid, maybe_rkey 134 ) 135 .into_boxed_str(), 136 )) 137 } else { 138 Some(CowStr::Borrowed(aturi)) 139 } 140} 141 142pub enum LinkUri<'a> { 143 AtRecord(AtUri<'a>), 144 AtIdent(Did<'a>, Handle<'a>), 145 Web(jacquard::url::Url), 146 Path(markdown_weaver::CowStr<'a>), 147 Heading(markdown_weaver::CowStr<'a>), 148 Footnote(markdown_weaver::CowStr<'a>), 149} 150 151impl<'a> LinkUri<'a> { 152 pub async fn resolve<A>(dest_url: &'a str, agent: &Agent<A>) -> LinkUri<'a> 153 where 154 A: AgentSession + IdentityResolver, 155 { 156 if dest_url.starts_with('@') { 157 if let Ok(handle) = Handle::new(dest_url) { 158 if let Ok(did) = agent.resolve_handle(&handle).await { 159 return Self::AtIdent(did, handle); 160 } 161 } 162 } else if dest_url.starts_with("did:") { 163 if let Ok(did) = Did::new(dest_url) { 164 if let Ok(doc) = agent.resolve_did_doc(&did).await { 165 if let Ok(doc) = doc.parse_validated() { 166 if let Some(handle) = doc.handles().first() { 167 return Self::AtIdent(did, handle.clone()); 168 } 169 } 170 } 171 } 172 } else if dest_url.starts_with('#') { 173 // local fragment 174 return Self::Heading(markdown_weaver::CowStr::Borrowed(dest_url)); 175 } else if dest_url.starts_with('^') { 176 // footnote 177 return Self::Footnote(markdown_weaver::CowStr::Borrowed(dest_url)); 178 } 179 if let Ok(url) = jacquard::url::Url::parse(dest_url) { 180 if let Some(uri) = jacquard::richtext::extract_at_uri_from_url( 181 url.as_str(), 182 jacquard::richtext::DEFAULT_EMBED_DOMAINS, 183 ) { 184 if let AtIdentifier::Handle(handle) = uri.authority() { 185 if let Ok(did) = agent.resolve_handle(handle).await { 186 let mut aturi = format!("at://{did}"); 187 if let Some(collection) = uri.collection() { 188 aturi.push_str(&format!("/{}", collection)); 189 if let Some(record) = uri.rkey() { 190 aturi.push_str(&format!("/{}", record.0)); 191 } 192 } 193 if let Ok(aturi) = AtUri::new_owned(aturi) { 194 return Self::AtRecord(aturi); 195 } 196 } 197 return Self::AtRecord(uri); 198 } else { 199 return Self::AtRecord(uri); 200 } 201 } else if url.scheme() == "http" || url.scheme() == "https" { 202 return Self::Web(url); 203 } 204 } 205 206 LinkUri::Path(markdown_weaver::CowStr::Borrowed(dest_url)) 207 } 208} 209 210pub fn normalize_title_path(title: &str) -> String { 211 title.replace(' ', "_").to_lowercase() 212} 213 214/// Convert a title to a URL-friendly slug. 215/// 216/// Lowercases, replaces whitespace/dashes/underscores with dashes, 217/// removes other non-alphanumeric characters, and collapses multiple dashes. 218pub fn slugify(title: &str) -> String { 219 title 220 .to_lowercase() 221 .chars() 222 .map(|c| { 223 if c.is_ascii_alphanumeric() { 224 c 225 } else if c.is_whitespace() || c == '-' || c == '_' { 226 '-' 227 } else { 228 '\0' 229 } 230 }) 231 .filter(|&c| c != '\0') 232 .collect::<String>() 233 .split('-') 234 .filter(|s| !s.is_empty()) 235 .collect::<Vec<_>>() 236 .join("-") 237}