···4455// Re-export jacquard for convenience
66pub use jacquard;
77-use jacquard::CowStr;
88-pub use jacquard_api;
77+use jacquard::error::ClientError;
88+use jacquard::types::ident::AtIdentifier;
99+use jacquard::{CowStr, IntoStatic, xrpc};
9101011pub use error::WeaverError;
1212+use jacquard::types::tid::{Ticker, Tid};
11131212-use jacquard::client::{Agent, AgentSession};
1313-use jacquard::types::blob::BlobRef;
1414-use jacquard::types::string::{AtUri, Cid, Did};
1515-use jacquard_api::sh_weaver::notebook::{book, chapter, entry};
1414+use jacquard::bytes::Bytes;
1515+use jacquard::client::{Agent, AgentError, AgentErrorKind, AgentSession, AgentSessionExt};
1616+use jacquard::prelude::*;
1717+use jacquard::smol_str::SmolStr;
1818+use jacquard::types::blob::{BlobRef, MimeType};
1919+use jacquard::types::string::{AtUri, Cid, Did, Handle, RecordKey};
2020+use jacquard::xrpc::Response;
1621use std::path::Path;
2222+use std::sync::LazyLock;
2323+use tokio::sync::Mutex;
2424+use weaver_api::com_atproto::repo::get_record::GetRecordResponse;
2525+use weaver_api::com_atproto::repo::strong_ref::StrongRef;
2626+use weaver_api::sh_weaver::notebook::{book, chapter, entry};
2727+use weaver_api::sh_weaver::publish::blob::Blob as PublishedBlob;
2828+2929+use crate::error::ParseError;
3030+3131+static W_TICKER: LazyLock<Mutex<Ticker>> = LazyLock::new(|| Mutex::new(Ticker::new()));
17321833/// Extension trait providing weaver-specific multi-step operations on Agent
1934///
···2641/// - `agent.upload_blob()` - Upload a single blob
2742///
2843/// This trait is for multi-step workflows that coordinate between multiple operations.
2929-#[trait_variant::make(Send)]
3030-pub trait WeaverExt {
4444+//#[trait_variant::make(Send)]
4545+pub trait WeaverExt: AgentSessionExt {
3146 /// Publish a notebook directory to the user's PDS
3247 ///
3348 /// Multi-step workflow:
···3853 /// 5. Create book record with entry refs
3954 ///
4055 /// Returns the AT-URI of the published book
4141- async fn publish_notebook(&self, path: &Path) -> Result<PublishResult<'_>, WeaverError>;
5656+ fn publish_notebook(
5757+ &self,
5858+ path: &Path,
5959+ ) -> impl Future<Output = Result<PublishResult<'_>, WeaverError>>;
42604343- /// Upload assets from markdown content
6161+ /// Publish a blob to the user's PDS
4462 ///
4563 /// Multi-step workflow:
4646- /// 1. Parse markdown for image/asset refs
4747- /// 2. Upload each asset → BlobRef
4848- /// 3. Return mapping of original path → BlobRef
6464+ /// 1. Upload blob to PDS
6565+ /// 2. Create blob record with CID
4966 ///
5050- /// Used by renderer to transform local refs to atproto refs
5151- async fn upload_assets(
6767+ /// Returns the AT-URI of the published blob
6868+ fn publish_blob<'a>(
6969+ &self,
7070+ blob: Bytes,
7171+ url_path: &'a str,
7272+ prev: Option<Tid>,
7373+ ) -> impl Future<Output = Result<(StrongRef<'a>, PublishedBlob<'a>), WeaverError>>;
7474+7575+ fn confirm_record_ref(
5276 &self,
5353- markdown: &str,
5454- ) -> Result<Vec<(String, BlobRef<'_>)>, WeaverError>;
7777+ uri: &AtUri<'_>,
7878+ ) -> impl Future<Output = Result<StrongRef<'_>, WeaverError>>;
5579}
56805757-impl<A: AgentSession> WeaverExt for Agent<A> {
8181+impl<A: AgentSession + IdentityResolver> WeaverExt for Agent<A> {
5882 async fn publish_notebook(&self, _path: &Path) -> Result<PublishResult<'_>, WeaverError> {
5983 // TODO: Implementation
6084 todo!("publish_notebook not yet implemented")
6185 }
62866363- async fn upload_assets(
8787+ async fn publish_blob<'a>(
6488 &self,
6565- _markdown: &str,
6666- ) -> Result<Vec<(String, BlobRef<'_>)>, WeaverError> {
6767- // TODO: Implementation
6868- todo!("upload_assets not yet implemented")
8989+ blob: Bytes,
9090+ url_path: &'a str,
9191+ prev: Option<Tid>,
9292+ ) -> Result<(StrongRef<'a>, PublishedBlob<'a>), WeaverError> {
9393+ let mime_type = MimeType::new_owned(tree_magic::from_u8(blob.as_ref()));
9494+9595+ let blob = self.upload_blob(blob, mime_type).await?;
9696+ let publish_record = PublishedBlob::new()
9797+ .path(url_path)
9898+ .upload(BlobRef::Blob(blob))
9999+ .build();
100100+ let tid = W_TICKER.lock().await.next(prev);
101101+ let record = self
102102+ .create_record(publish_record.clone(), Some(RecordKey::any(tid.as_str())?))
103103+ .await?;
104104+ let strong_ref = StrongRef::new().uri(record.uri).cid(record.cid).build();
105105+106106+ Ok((strong_ref, publish_record))
107107+ }
108108+109109+ async fn confirm_record_ref(&self, uri: &AtUri<'_>) -> Result<StrongRef<'_>, WeaverError> {
110110+ let rkey = uri.rkey().ok_or_else(|| {
111111+ AgentError::from(
112112+ ClientError::invalid_request("AtUri missing rkey")
113113+ .with_help("ensure the URI includes a record key after the collection"),
114114+ )
115115+ })?;
116116+117117+ // Resolve authority (DID or handle) to get DID and PDS
118118+ use jacquard::types::ident::AtIdentifier;
119119+ let (repo_did, pds_url) = match uri.authority() {
120120+ AtIdentifier::Did(did) => {
121121+ let pds = self.pds_for_did(did).await.map_err(|e| {
122122+ AgentError::from(
123123+ ClientError::from(e)
124124+ .with_context("DID document resolution failed during record retrieval"),
125125+ )
126126+ })?;
127127+ (did.clone(), pds)
128128+ }
129129+ AtIdentifier::Handle(handle) => self.pds_for_handle(handle).await.map_err(|e| {
130130+ AgentError::from(
131131+ ClientError::from(e)
132132+ .with_context("handle resolution failed during record retrieval"),
133133+ )
134134+ })?,
135135+ };
136136+137137+ // Make stateless XRPC call to that PDS (no auth required for public records)
138138+ use weaver_api::com_atproto::repo::get_record::GetRecord;
139139+ let request = GetRecord::new()
140140+ .repo(AtIdentifier::Did(repo_did))
141141+ .collection(
142142+ uri.collection()
143143+ .expect("collection should exist if rkey does")
144144+ .clone(),
145145+ )
146146+ .rkey(rkey.clone())
147147+ .build();
148148+149149+ let response: Response<GetRecordResponse> = {
150150+ let http_request = xrpc::build_http_request(&pds_url, &request, &self.opts().await)
151151+ .map_err(|e| AgentError::from(ClientError::transport(e)))?;
152152+153153+ let http_response = self
154154+ .send_http(http_request)
155155+ .await
156156+ .map_err(|e| AgentError::from(ClientError::transport(e)))?;
157157+158158+ xrpc::process_response(http_response)
159159+ }
160160+ .map_err(|e| AgentError::new(AgentErrorKind::Client, Some(e.into())))?;
161161+ let record = response.parse().map_err(|e| AgentError::xrpc(e))?;
162162+ let strong_ref = StrongRef::new()
163163+ .uri(record.uri)
164164+ .cid(record.cid.expect("when does this NOT have a CID?"))
165165+ .build();
166166+ Ok(strong_ref.into_static())
69167 }
70168}
71169···77175 /// CID of the book record
78176 pub cid: Cid<'a>,
79177 /// URIs of published entries
8080- pub entries: Vec<AtUri<'a>>,
178178+ pub entries: Vec<StrongRef<'a>>,
81179}
8218083181/// too many cows, so we have conversions
···173271 Some(CowStr::Borrowed(aturi))
174272 }
175273}
274274+275275+pub enum LinkUri<'a> {
276276+ AtRecord(AtUri<'a>),
277277+ AtIdent(Did<'a>, Handle<'a>),
278278+ Web(jacquard::url::Url),
279279+ Path(markdown_weaver::CowStr<'a>),
280280+ Heading(markdown_weaver::CowStr<'a>),
281281+ Footnote(markdown_weaver::CowStr<'a>),
282282+}
283283+284284+impl<'a> LinkUri<'a> {
285285+ pub async fn resolve<A>(dest_url: &'a str, agent: &Agent<A>) -> LinkUri<'a>
286286+ where
287287+ A: AgentSession + IdentityResolver,
288288+ {
289289+ if dest_url.starts_with('@') {
290290+ if let Ok(handle) = Handle::new(dest_url) {
291291+ if let Ok(did) = agent.resolve_handle(&handle).await {
292292+ return Self::AtIdent(did, handle);
293293+ }
294294+ }
295295+ } else if dest_url.starts_with("did:") {
296296+ if let Ok(did) = Did::new(dest_url) {
297297+ if let Ok(doc) = agent.resolve_did_doc(&did).await {
298298+ if let Ok(doc) = doc.parse_validated() {
299299+ if let Some(handle) = doc.handles().first() {
300300+ return Self::AtIdent(did, handle.clone());
301301+ }
302302+ }
303303+ }
304304+ }
305305+ } else if dest_url.starts_with('#') {
306306+ // local fragment
307307+ return Self::Heading(markdown_weaver::CowStr::Borrowed(dest_url));
308308+ } else if dest_url.starts_with('^') {
309309+ // footnote
310310+ return Self::Footnote(markdown_weaver::CowStr::Borrowed(dest_url));
311311+ }
312312+ if let Ok(url) = jacquard::url::Url::parse(dest_url) {
313313+ if let Some(uri) = jacquard::richtext::extract_at_uri_from_url(
314314+ url.as_str(),
315315+ jacquard::richtext::DEFAULT_EMBED_DOMAINS,
316316+ ) {
317317+ if let AtIdentifier::Handle(handle) = uri.authority() {
318318+ if let Ok(did) = agent.resolve_handle(handle).await {
319319+ let mut aturi = format!("at://{did}");
320320+ if let Some(collection) = uri.collection() {
321321+ aturi.push_str(&format!("/{}", collection));
322322+ if let Some(record) = uri.rkey() {
323323+ aturi.push_str(&format!("/{}", record.0));
324324+ }
325325+ }
326326+ if let Ok(aturi) = AtUri::new_owned(aturi) {
327327+ return Self::AtRecord(aturi);
328328+ }
329329+ }
330330+ return Self::AtRecord(uri);
331331+ } else {
332332+ return Self::AtRecord(uri);
333333+ }
334334+ } else if url.scheme() == "http" || url.scheme() == "https" {
335335+ return Self::Web(url);
336336+ }
337337+ }
338338+339339+ LinkUri::Path(markdown_weaver::CowStr::Borrowed(dest_url))
340340+ }
341341+}
-3
crates/weaver-renderer/src/lib.rs
···33//! This crate works with the weaver-markdown crate to render and optionally upload markdown notebooks to your Atproto PDS.
44//!
5566-use async_trait::async_trait;
76use markdown_weaver::CowStr;
87use markdown_weaver::Event;
99-use markdown_weaver::LinkType;
108use markdown_weaver::Tag;
119use n0_future::Stream;
1212-use n0_future::StreamExt;
1310use n0_future::pin;
1411use n0_future::stream::once_future;
1512use yaml_rust2::Yaml;