reworked nav and profile hydration

Orual 999e5691 67dfd1c3

+1257 -892
+2
Cargo.lock
··· 8631 8631 "mime-sniffer", 8632 8632 "mini-moka", 8633 8633 "n0-future", 8634 + "reqwest", 8635 + "serde", 8634 8636 "serde_json", 8635 8637 "sqlite-wasm-rs", 8636 8638 "time",
+2 -1
crates/weaver-api/lexicons/sh_weaver_notebook_defs.json
··· 13 13 "type": "integer" 14 14 }, 15 15 "record": { 16 - "type": "unknown" 16 + "type": "ref", 17 + "ref": "sh.weaver.actor.defs#profileDataView" 17 18 }, 18 19 "uri": { 19 20 "type": "string",
+5
crates/weaver-api/lexicons/sh_weaver_notebook_entry.json
··· 11 11 "required": [ 12 12 "content", 13 13 "title", 14 + "path", 14 15 "createdAt" 15 16 ], 16 17 "properties": { ··· 52 53 "ref": "sh.weaver.embed.video" 53 54 } 54 55 } 56 + }, 57 + "path": { 58 + "type": "ref", 59 + "ref": "sh.weaver.notebook.defs#path" 55 60 }, 56 61 "tags": { 57 62 "type": "ref",
+7 -4
crates/weaver-api/src/sh_weaver/notebook.rs
··· 27 27 pub struct AuthorListView<'a> { 28 28 pub index: i64, 29 29 #[serde(borrow)] 30 - pub record: jacquard_common::types::value::Data<'a>, 30 + pub record: crate::sh_weaver::actor::ProfileDataView<'a>, 31 31 #[serde(skip_serializing_if = "std::option::Option::is_none")] 32 32 #[serde(borrow)] 33 33 pub uri: Option<jacquard_common::types::string::AtUri<'a>>, ··· 82 82 _phantom_state: ::core::marker::PhantomData<fn() -> S>, 83 83 __unsafe_private_named: ( 84 84 ::core::option::Option<i64>, 85 - ::core::option::Option<jacquard_common::types::value::Data<'a>>, 85 + ::core::option::Option<crate::sh_weaver::actor::ProfileDataView<'a>>, 86 86 ::core::option::Option<jacquard_common::types::string::AtUri<'a>>, 87 87 ), 88 88 _phantom: ::core::marker::PhantomData<&'a ()>, ··· 133 133 /// Set the `record` field (required) 134 134 pub fn record( 135 135 mut self, 136 - value: impl Into<jacquard_common::types::value::Data<'a>>, 136 + value: impl Into<crate::sh_weaver::actor::ProfileDataView<'a>>, 137 137 ) -> AuthorListViewBuilder<'a, author_list_view_state::SetRecord<S>> { 138 138 self.__unsafe_private_named.1 = ::core::option::Option::Some(value.into()); 139 139 AuthorListViewBuilder { ··· 232 232 ); 233 233 map.insert( 234 234 ::jacquard_common::smol_str::SmolStr::new_static("record"), 235 - ::jacquard_lexicon::lexicon::LexObjectProperty::Unknown(::jacquard_lexicon::lexicon::LexUnknown { 235 + ::jacquard_lexicon::lexicon::LexObjectProperty::Ref(::jacquard_lexicon::lexicon::LexRef { 236 236 description: None, 237 + r#ref: ::jacquard_common::CowStr::new_static( 238 + "sh.weaver.actor.defs#profileDataView", 239 + ), 237 240 }), 238 241 ); 239 242 map.insert(
+59 -8
crates/weaver-api/src/sh_weaver/notebook/entry.rs
··· 27 27 #[serde(skip_serializing_if = "std::option::Option::is_none")] 28 28 #[serde(borrow)] 29 29 pub embeds: Option<EntryEmbeds<'a>>, 30 + #[serde(borrow)] 31 + pub path: crate::sh_weaver::notebook::Path<'a>, 30 32 #[serde(skip_serializing_if = "std::option::Option::is_none")] 31 33 #[serde(borrow)] 32 34 pub tags: Option<crate::sh_weaver::notebook::Tags<'a>>, ··· 46 48 pub trait State: sealed::Sealed { 47 49 type Content; 48 50 type Title; 51 + type Path; 49 52 type CreatedAt; 50 53 } 51 54 /// Empty state - all required fields are unset ··· 54 57 impl State for Empty { 55 58 type Content = Unset; 56 59 type Title = Unset; 60 + type Path = Unset; 57 61 type CreatedAt = Unset; 58 62 } 59 63 ///State transition - sets the `content` field to Set ··· 62 66 impl<S: State> State for SetContent<S> { 63 67 type Content = Set<members::content>; 64 68 type Title = S::Title; 69 + type Path = S::Path; 65 70 type CreatedAt = S::CreatedAt; 66 71 } 67 72 ///State transition - sets the `title` field to Set ··· 70 75 impl<S: State> State for SetTitle<S> { 71 76 type Content = S::Content; 72 77 type Title = Set<members::title>; 78 + type Path = S::Path; 79 + type CreatedAt = S::CreatedAt; 80 + } 81 + ///State transition - sets the `path` field to Set 82 + pub struct SetPath<S: State = Empty>(PhantomData<fn() -> S>); 83 + impl<S: State> sealed::Sealed for SetPath<S> {} 84 + impl<S: State> State for SetPath<S> { 85 + type Content = S::Content; 86 + type Title = S::Title; 87 + type Path = Set<members::path>; 73 88 type CreatedAt = S::CreatedAt; 74 89 } 75 90 ///State transition - sets the `created_at` field to Set ··· 78 93 impl<S: State> State for SetCreatedAt<S> { 79 94 type Content = S::Content; 80 95 type Title = S::Title; 96 + type Path = S::Path; 81 97 type CreatedAt = Set<members::created_at>; 82 98 } 83 99 /// Marker types for field names ··· 87 103 pub struct content(()); 88 104 ///Marker type for the `title` field 89 105 pub struct title(()); 106 + ///Marker type for the `path` field 107 + pub struct path(()); 90 108 ///Marker type for the `created_at` field 91 109 pub struct created_at(()); 92 110 } ··· 99 117 ::core::option::Option<jacquard_common::CowStr<'a>>, 100 118 ::core::option::Option<jacquard_common::types::string::Datetime>, 101 119 ::core::option::Option<EntryEmbeds<'a>>, 120 + ::core::option::Option<crate::sh_weaver::notebook::Path<'a>>, 102 121 ::core::option::Option<crate::sh_weaver::notebook::Tags<'a>>, 103 122 ::core::option::Option<crate::sh_weaver::notebook::Title<'a>>, 104 123 ), ··· 117 136 pub fn new() -> Self { 118 137 EntryBuilder { 119 138 _phantom_state: ::core::marker::PhantomData, 120 - __unsafe_private_named: (None, None, None, None, None), 139 + __unsafe_private_named: (None, None, None, None, None, None), 121 140 _phantom: ::core::marker::PhantomData, 122 141 } 123 142 } ··· 174 193 } 175 194 } 176 195 196 + impl<'a, S> EntryBuilder<'a, S> 197 + where 198 + S: entry_state::State, 199 + S::Path: entry_state::IsUnset, 200 + { 201 + /// Set the `path` field (required) 202 + pub fn path( 203 + mut self, 204 + value: impl Into<crate::sh_weaver::notebook::Path<'a>>, 205 + ) -> EntryBuilder<'a, entry_state::SetPath<S>> { 206 + self.__unsafe_private_named.3 = ::core::option::Option::Some(value.into()); 207 + EntryBuilder { 208 + _phantom_state: ::core::marker::PhantomData, 209 + __unsafe_private_named: self.__unsafe_private_named, 210 + _phantom: ::core::marker::PhantomData, 211 + } 212 + } 213 + } 214 + 177 215 impl<'a, S: entry_state::State> EntryBuilder<'a, S> { 178 216 /// Set the `tags` field (optional) 179 217 pub fn tags( 180 218 mut self, 181 219 value: impl Into<Option<crate::sh_weaver::notebook::Tags<'a>>>, 182 220 ) -> Self { 183 - self.__unsafe_private_named.3 = value.into(); 221 + self.__unsafe_private_named.4 = value.into(); 184 222 self 185 223 } 186 224 /// Set the `tags` field to an Option value (optional) ··· 188 226 mut self, 189 227 value: Option<crate::sh_weaver::notebook::Tags<'a>>, 190 228 ) -> Self { 191 - self.__unsafe_private_named.3 = value; 229 + self.__unsafe_private_named.4 = value; 192 230 self 193 231 } 194 232 } ··· 203 241 mut self, 204 242 value: impl Into<crate::sh_weaver::notebook::Title<'a>>, 205 243 ) -> EntryBuilder<'a, entry_state::SetTitle<S>> { 206 - self.__unsafe_private_named.4 = ::core::option::Option::Some(value.into()); 244 + self.__unsafe_private_named.5 = ::core::option::Option::Some(value.into()); 207 245 EntryBuilder { 208 246 _phantom_state: ::core::marker::PhantomData, 209 247 __unsafe_private_named: self.__unsafe_private_named, ··· 217 255 S: entry_state::State, 218 256 S::Content: entry_state::IsSet, 219 257 S::Title: entry_state::IsSet, 258 + S::Path: entry_state::IsSet, 220 259 S::CreatedAt: entry_state::IsSet, 221 260 { 222 261 /// Build the final struct ··· 225 264 content: self.__unsafe_private_named.0.unwrap(), 226 265 created_at: self.__unsafe_private_named.1.unwrap(), 227 266 embeds: self.__unsafe_private_named.2, 228 - tags: self.__unsafe_private_named.3, 229 - title: self.__unsafe_private_named.4.unwrap(), 267 + path: self.__unsafe_private_named.3.unwrap(), 268 + tags: self.__unsafe_private_named.4, 269 + title: self.__unsafe_private_named.5.unwrap(), 230 270 extra_data: Default::default(), 231 271 } 232 272 } ··· 242 282 content: self.__unsafe_private_named.0.unwrap(), 243 283 created_at: self.__unsafe_private_named.1.unwrap(), 244 284 embeds: self.__unsafe_private_named.2, 245 - tags: self.__unsafe_private_named.3, 246 - title: self.__unsafe_private_named.4.unwrap(), 285 + path: self.__unsafe_private_named.3.unwrap(), 286 + tags: self.__unsafe_private_named.4, 287 + title: self.__unsafe_private_named.5.unwrap(), 247 288 extra_data: Some(extra_data), 248 289 } 249 290 } ··· 318 359 vec![ 319 360 ::jacquard_common::smol_str::SmolStr::new_static("content"), 320 361 ::jacquard_common::smol_str::SmolStr::new_static("title"), 362 + ::jacquard_common::smol_str::SmolStr::new_static("path"), 321 363 ::jacquard_common::smol_str::SmolStr::new_static("createdAt") 322 364 ], 323 365 ), ··· 436 478 ); 437 479 map 438 480 }, 481 + }), 482 + ); 483 + map.insert( 484 + ::jacquard_common::smol_str::SmolStr::new_static("path"), 485 + ::jacquard_lexicon::lexicon::LexObjectProperty::Ref(::jacquard_lexicon::lexicon::LexRef { 486 + description: None, 487 + r#ref: ::jacquard_common::CowStr::new_static( 488 + "sh.weaver.notebook.defs#path", 489 + ), 439 490 }), 440 491 ); 441 492 map.insert(
+2
crates/weaver-app/Cargo.toml
··· 35 35 axum = {version = "0.8.6", optional = true} 36 36 mime-sniffer = {version = "^0.1"} 37 37 chrono = { version = "0.4" } 38 + serde = { version = "1.0", features = ["derive"] } 38 39 serde_json = "1.0" 40 + reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } 39 41 40 42 diesel = { version = "2.3", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "chrono", "serde_json"] } 41 43 diesel_migrations = { version = "2.3", features = ["sqlite"] }
+26 -11
crates/weaver-app/assets/styling/navbar.css
··· 1 1 #navbar { 2 - display: flex; 3 - flex-direction: row; 2 + display: flex; 3 + flex-direction: row; 4 + } 5 + 6 + .breadcrumbs { 7 + display: flex; 8 + align-items: center; 9 + gap: 0.5rem; 10 + } 11 + 12 + .breadcrumb { 13 + color: var(--color-text); 14 + text-decoration: none; 15 + transition: color 0.2s ease; 16 + } 17 + 18 + .breadcrumb:hover { 19 + cursor: pointer; 20 + color: var(--color-primary); 4 21 } 5 22 6 - #navbar a { 7 - color: var(--color-text); 8 - margin-right: 20px; 9 - text-decoration: none; 10 - transition: color 0.2s ease; 23 + .breadcrumb-current { 24 + color: var(--color-text-muted, #666); 25 + font-weight: 500; 11 26 } 12 27 13 - #navbar a:hover { 14 - cursor: pointer; 15 - color: var(--color-primary); 16 - } 28 + .breadcrumb-separator { 29 + color: var(--color-text-muted, #999); 30 + user-select: none; 31 + }
+2
crates/weaver-app/src/components/avatar/component.rs
··· 5 5 pub enum AvatarImageSize { 6 6 #[default] 7 7 Small, 8 + #[allow(dead_code)] 8 9 Medium, 10 + #[allow(dead_code)] 9 11 Large, 10 12 } 11 13
+3 -6
crates/weaver-app/src/components/css.rs
··· 4 4 use dioxus::{prelude::*, CapturedError}; 5 5 6 6 #[cfg(feature = "fullstack-server")] 7 - use dioxus::fullstack::{ 8 - get_server_url, 9 - headers::ContentType, 10 - http::header::CONTENT_TYPE, 11 - response::{self, Response}, 12 - }; 7 + use dioxus::fullstack::response::Response; 13 8 use jacquard::smol_str::SmolStr; 14 9 #[allow(unused_imports)] 15 10 use std::sync::Arc; ··· 22 17 #[cfg(feature = "fullstack-server")] 23 18 #[component] 24 19 pub fn NotebookCss(ident: SmolStr, notebook: SmolStr) -> Element { 20 + use dioxus::fullstack::get_server_url; 25 21 rsx! { 26 22 document::Stylesheet { 27 23 href: "{get_server_url()}/{ident}/{notebook}/css" ··· 93 89 #[cfg(feature = "fullstack-server")] 94 90 #[get("/{ident}/{notebook}/css", fetcher: Extension<Arc<fetch::CachedFetcher>>)] 95 91 pub async fn css(ident: SmolStr, notebook: SmolStr) -> Result<Response> { 92 + use dioxus::fullstack::http::header::CONTENT_TYPE; 96 93 use jacquard::client::AgentSessionExt; 97 94 use jacquard::types::ident::AtIdentifier; 98 95 use jacquard::{from_data, CowStr};
+61 -39
crates/weaver-app/src/components/entry.rs
··· 3 3 #[cfg(feature = "server")] 4 4 use crate::blobcache::BlobCache; 5 5 use crate::{ 6 - components::avatar::{Avatar, AvatarFallback, AvatarImage}, 6 + components::avatar::{Avatar, AvatarImage}, 7 7 data::use_handle, 8 - fetch, 9 8 }; 10 9 11 10 use crate::Route; ··· 13 12 14 13 const ENTRY_CSS: Asset = asset!("/assets/styling/entry.css"); 15 14 16 - use jacquard::prelude::*; 17 15 #[allow(unused_imports)] 18 16 use jacquard::smol_str::ToSmolStr; 19 - use jacquard::{from_data, types::string::Datetime}; 17 + use jacquard::types::string::Datetime; 20 18 #[allow(unused_imports)] 21 19 use jacquard::{ 22 20 smol_str::SmolStr, ··· 166 164 pub fn EntryCard(entry: BookEntryView<'static>, book_title: SmolStr) -> Element { 167 165 use crate::Route; 168 166 use jacquard::{from_data, IntoStatic}; 169 - use weaver_api::app_bsky::actor::profile::Profile; 170 167 use weaver_api::sh_weaver::notebook::entry::Entry; 171 168 172 169 let entry_view = &entry.entry; ··· 219 216 if let Some(author) = first_author { 220 217 div { class: "entry-card-author", 221 218 { 222 - match author.record.get_at_path(".value").and_then(|v| from_data::<Profile>(v).ok()) { 223 - Some(profile) => { 224 - let avatar = profile.avatar 225 - .map(|avatar| { 226 - let cid = avatar.blob().cid(); 227 - format!("https://cdn.bsky.app/img/avatar/plain/{}/{cid}@jpeg", entry_view.uri.authority().as_ref()) 228 - }); 229 - let display_name = profile.display_name 230 - .as_ref() 231 - .map(|n| n.as_ref()) 232 - .unwrap_or("Unknown"); 219 + use weaver_api::sh_weaver::actor::ProfileDataViewInner; 220 + 221 + match &author.record.inner { 222 + ProfileDataViewInner::ProfileView(profile) => { 223 + let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown"); 233 224 rsx! { 234 - if let Some(avatar_url) = avatar { 225 + if let Some(ref avatar_url) = profile.avatar { 235 226 Avatar { 236 - AvatarImage { src: avatar_url } 227 + AvatarImage { src: avatar_url.as_ref() } 237 228 } 238 229 } 239 230 span { class: "author-name", "{display_name}" } 240 231 span { class: "meta-label", "@{ident}" } 241 232 } 242 233 } 243 - None => { 234 + ProfileDataViewInner::ProfileViewDetailed(profile) => { 235 + let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown"); 244 236 rsx! { 245 - span { class: "author-name", "Author {author.index}" } 237 + if let Some(ref avatar_url) = profile.avatar { 238 + Avatar { 239 + AvatarImage { src: avatar_url.as_ref() } 240 + } 241 + } 242 + span { class: "author-name", "{display_name}" } 243 + span { class: "meta-label", "@{ident}" } 244 + } 245 + } 246 + ProfileDataViewInner::TangledProfileView(profile) => { 247 + rsx! { 248 + span { class: "author-name", "@{profile.handle.as_ref()}" } 249 + } 250 + } 251 + _ => { 252 + rsx! { 253 + span { class: "author-name", "Unknown" } 246 254 } 247 255 } 248 256 } ··· 279 287 ident: AtIdentifier<'static>, 280 288 created_at: Datetime, 281 289 ) -> Element { 282 - use weaver_api::app_bsky::actor::profile::Profile; 283 - 284 290 let title = entry_view 285 291 .title 286 292 .as_ref() ··· 300 306 for (i, author) in entry_view.authors.iter().enumerate() { 301 307 if i > 0 { span { ", " } } 302 308 { 303 - // Parse author profile from the nested value field 304 - match author.record.get_at_path(".value").and_then(|v| from_data::<Profile>(v).ok()) { 305 - Some(profile) => { 306 - let avatar = profile.avatar 307 - .map(|avatar| { 308 - let cid = avatar.blob().cid(); 309 - let did = entry_view.uri.authority(); 310 - format!("https://cdn.bsky.app/img/avatar/plain/{did}/{cid}@jpeg") 311 - }); 312 - let display_name = profile.display_name 313 - .as_ref() 314 - .map(|n| n.as_ref()) 315 - .unwrap_or("Unknown"); 309 + use weaver_api::sh_weaver::actor::ProfileDataViewInner; 310 + 311 + match &author.record.inner { 312 + ProfileDataViewInner::ProfileView(profile) => { 313 + let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown"); 316 314 rsx! { 317 315 Link { 318 316 to: Route::RepositoryIndex { ident: ident.clone() }, 319 317 div { class: "entry-authors", 320 - if let Some(avatar) = avatar { 318 + if let Some(ref avatar_url) = profile.avatar { 321 319 Avatar { 322 320 AvatarImage { 323 - src: avatar 321 + src: avatar_url.as_ref() 324 322 } 325 323 } 326 324 } ··· 330 328 } 331 329 } 332 330 } 333 - None => { 331 + ProfileDataViewInner::ProfileViewDetailed(profile) => { 332 + let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown"); 334 333 rsx! { 335 - span { class: "author-name", "Author {author.index}" } 334 + Link { 335 + to: Route::RepositoryIndex { ident: ident.clone() }, 336 + div { class: "entry-authors", 337 + if let Some(ref avatar_url) = profile.avatar { 338 + Avatar { 339 + AvatarImage { 340 + src: avatar_url.as_ref() 341 + } 342 + } 343 + } 344 + span { class: "author-name", "{display_name}" } 345 + span { class: "meta-label", "@{ident}" } 346 + } 347 + } 348 + } 349 + } 350 + ProfileDataViewInner::TangledProfileView(profile) => { 351 + rsx! { 352 + span { class: "author-name", "@{profile.handle.as_ref()}" } 353 + } 354 + } 355 + _ => { 356 + rsx! { 357 + span { class: "author-name", "Unknown" } 336 358 } 337 359 } 338 360 }
+50 -28
crates/weaver-app/src/components/identity.rs
··· 18 18 #[component] 19 19 pub fn RepositoryIndex(ident: AtIdentifier<'static>) -> Element { 20 20 let fetcher = use_context::<fetch::CachedFetcher>(); 21 - let notebooks = use_signal(|| fetcher.list_recent_notebooks()); 21 + 22 + // Fetch notebooks for this specific DID 23 + let notebooks = use_resource(use_reactive!(|ident| { 24 + let fetcher = fetcher.clone(); 25 + async move { fetcher.fetch_notebooks_for_did(&ident).await } 26 + })); 27 + 22 28 rsx! { 23 29 document::Link { rel: "stylesheet", href: NOTEBOOK_CARD_CSS } 24 30 25 31 div { class: "notebooks-list", 26 - for notebook in notebooks.iter() { 27 - { 28 - let view = &notebook.0; 29 - rsx! { 30 - div { 31 - key: "{view.cid}", 32 - NotebookCard { notebook: view.clone() } 32 + match notebooks() { 33 + Some(Ok(notebook_list)) => rsx! { 34 + for notebook in notebook_list.iter() { 35 + { 36 + let view = &notebook.0; 37 + rsx! { 38 + div { 39 + key: "{view.cid}", 40 + NotebookCard { notebook: view.clone() } 41 + } 42 + } 33 43 } 34 44 } 45 + }, 46 + Some(Err(_)) => rsx! { 47 + div { "Error loading notebooks" } 48 + }, 49 + None => rsx! { 50 + div { "Loading notebooks..." } 35 51 } 36 52 } 37 53 } ··· 41 57 #[component] 42 58 pub fn NotebookCard(notebook: NotebookView<'static>) -> Element { 43 59 use crate::components::avatar::{Avatar, AvatarImage}; 44 - use jacquard::{from_data, prelude::IdentityResolver, IntoStatic}; 45 - use weaver_api::app_bsky::actor::profile::Profile; 46 - use weaver_api::sh_weaver::notebook::book::Book; 60 + use jacquard::IntoStatic; 47 61 48 62 let title = notebook 49 63 .title ··· 58 72 let first_author = notebook.authors.first(); 59 73 60 74 let ident = notebook.uri.authority().clone().into_static(); 61 - let ident_for_avatar = ident.clone(); 62 - 63 75 rsx! { 64 76 div { class: "notebook-card", 65 77 Link { ··· 78 90 if let Some(author) = first_author { 79 91 div { class: "notebook-card-author", 80 92 { 81 - match from_data::<Profile>(author.record.get_at_path(".value").unwrap()) { 82 - Ok(profile) => { 83 - let avatar = profile.avatar 84 - .map(|avatar| { 85 - let cid = avatar.blob().cid(); 86 - format!("https://cdn.bsky.app/img/avatar/plain/{}/{cid}@jpeg", ident_for_avatar.as_ref()) 87 - }); 88 - let display_name = profile.display_name 89 - .as_ref() 90 - .map(|n| n.as_ref()) 91 - .unwrap_or("Unknown"); 93 + use weaver_api::sh_weaver::actor::ProfileDataViewInner; 94 + 95 + match &author.record.inner { 96 + ProfileDataViewInner::ProfileView(profile) => { 97 + let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown"); 98 + rsx! { 99 + if let Some(ref avatar_url) = profile.avatar { 100 + Avatar { 101 + AvatarImage { src: avatar_url.as_ref() } 102 + } 103 + } 104 + span { class: "author-name", "{display_name}" } 105 + } 106 + } 107 + ProfileDataViewInner::ProfileViewDetailed(profile) => { 108 + let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown"); 92 109 rsx! { 93 - if let Some(avatar_url) = avatar { 110 + if let Some(ref avatar_url) = profile.avatar { 94 111 Avatar { 95 - AvatarImage { src: avatar_url } 112 + AvatarImage { src: avatar_url.as_ref() } 96 113 } 97 114 } 98 115 span { class: "author-name", "{display_name}" } 99 116 } 100 117 } 101 - Err(_) => { 118 + ProfileDataViewInner::TangledProfileView(profile) => { 119 + rsx! { 120 + span { class: "author-name", "@{profile.handle.as_ref()}" } 121 + } 122 + } 123 + _ => { 102 124 rsx! { 103 - span { class: "author-name", "Author {author.index}" } 125 + span { class: "author-name", "Unknown" } 104 126 } 105 127 } 106 128 }
+134 -18
crates/weaver-app/src/fetch.rs
··· 1 1 use crate::cache_impl; 2 2 use dioxus::Result; 3 + use jacquard::prelude::*; 3 4 use jacquard::{client::BasicClient, smol_str::SmolStr, types::ident::AtIdentifier}; 5 + use serde::{Deserialize, Serialize}; 4 6 use std::{sync::Arc, time::Duration}; 5 7 use weaver_api::{ 6 8 com_atproto::repo::strong_ref::StrongRef, 7 9 sh_weaver::notebook::{entry::Entry, BookEntryView, NotebookView}, 8 10 }; 9 - use weaver_common::view::{entry_by_title, notebook_by_title}; 11 + use weaver_common::WeaverExt; 12 + 13 + #[derive(Debug, Clone, Deserialize, Serialize)] 14 + struct UfosRecord { 15 + collection: String, 16 + did: String, 17 + record: serde_json::Value, 18 + rkey: String, 19 + time_us: u64, 20 + } 10 21 11 22 #[derive(Clone)] 12 23 pub struct CachedFetcher { ··· 39 50 Ok(Some(entry)) 40 51 } else { 41 52 if let Some((notebook, entries)) = 42 - notebook_by_title(self.client.clone(), &ident, &title) 53 + self.client 54 + .notebook_by_title(&ident, &title) 43 55 .await 44 56 .map_err(|e| dioxus::CapturedError::from_display(e))? 45 57 { ··· 65 77 { 66 78 Ok(Some(entry)) 67 79 } else { 68 - if let Some(entry) = entry_by_title( 69 - self.client.clone(), 70 - notebook, 71 - entries.as_ref(), 72 - &entry_title, 73 - ) 74 - .await 75 - .map_err(|e| dioxus::CapturedError::from_display(e))? 80 + if let Some(entry) = self 81 + .client 82 + .entry_by_title(notebook, entries.as_ref(), &entry_title) 83 + .await 84 + .map_err(|e| dioxus::CapturedError::from_display(e))? 76 85 { 77 86 let stored = Arc::new(entry); 78 87 cache_impl::insert(&self.entry_cache, (ident, entry_title), stored.clone()); ··· 86 95 } 87 96 } 88 97 89 - pub fn list_recent_entries(&self) -> Vec<Arc<(BookEntryView<'static>, Entry<'static>)>> { 90 - cache_impl::iter(&self.entry_cache) 98 + pub async fn fetch_notebooks_from_ufos( 99 + &self, 100 + ) -> Result<Vec<Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>>> { 101 + use jacquard::{types::aturi::AtUri, IntoStatic}; 102 + 103 + let url = "https://ufos-api.microcosm.blue/records?collection=sh.weaver.notebook.book"; 104 + let response = reqwest::get(url) 105 + .await 106 + .map_err(|e| dioxus::CapturedError::from_display(e))?; 107 + 108 + let records: Vec<UfosRecord> = response 109 + .json() 110 + .await 111 + .map_err(|e| dioxus::CapturedError::from_display(e))?; 112 + 113 + let mut notebooks = Vec::new(); 114 + 115 + for ufos_record in records { 116 + // Construct URI 117 + let uri_str = format!( 118 + "at://{}/{}/{}", 119 + ufos_record.did, ufos_record.collection, ufos_record.rkey 120 + ); 121 + let uri = AtUri::new_owned(uri_str) 122 + .map_err(|e| dioxus::CapturedError::from_display(format!("Invalid URI: {}", e)))?; 123 + 124 + // Fetch the full notebook view (which hydrates authors) 125 + match self.client.view_notebook(&uri).await { 126 + Ok((notebook, entries)) => { 127 + let ident = uri.authority().clone().into_static(); 128 + let title = notebook 129 + .title 130 + .as_ref() 131 + .map(|t| SmolStr::new(t.as_ref())) 132 + .unwrap_or_else(|| SmolStr::new("Untitled")); 133 + 134 + let result = Arc::new((notebook, entries)); 135 + // Cache it 136 + cache_impl::insert(&self.book_cache, (ident, title), result.clone()); 137 + notebooks.push(result); 138 + } 139 + Err(_) => continue, // Skip notebooks that fail to load 140 + } 141 + } 142 + 143 + Ok(notebooks) 91 144 } 92 145 93 - pub fn list_recent_notebooks( 146 + pub async fn fetch_notebooks_for_did( 94 147 &self, 95 - ) -> Vec<Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>> { 96 - cache_impl::iter(&self.book_cache) 148 + ident: &AtIdentifier<'_>, 149 + ) -> Result<Vec<Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>>> { 150 + use jacquard::{ 151 + types::{collection::Collection, nsid::Nsid}, 152 + xrpc::XrpcExt, 153 + IntoStatic, 154 + }; 155 + use weaver_api::{ 156 + com_atproto::repo::list_records::ListRecords, sh_weaver::notebook::book::Book, 157 + }; 158 + 159 + // Resolve DID and PDS 160 + let (repo_did, pds_url) = match ident { 161 + AtIdentifier::Did(did) => { 162 + let pds = self 163 + .client 164 + .pds_for_did(did) 165 + .await 166 + .map_err(|e| dioxus::CapturedError::from_display(e))?; 167 + (did.clone(), pds) 168 + } 169 + AtIdentifier::Handle(handle) => self 170 + .client 171 + .pds_for_handle(handle) 172 + .await 173 + .map_err(|e| dioxus::CapturedError::from_display(e))?, 174 + }; 175 + 176 + // Fetch all notebook records for this repo 177 + let resp = self 178 + .client 179 + .xrpc(pds_url) 180 + .send( 181 + &ListRecords::new() 182 + .repo(repo_did) 183 + .collection(Nsid::raw(Book::NSID)) 184 + .limit(100) 185 + .build(), 186 + ) 187 + .await 188 + .map_err(|e| dioxus::CapturedError::from_display(e))?; 189 + 190 + let mut notebooks = Vec::new(); 191 + 192 + if let Ok(list) = resp.parse() { 193 + for record in list.records { 194 + // View the notebook (which hydrates authors) 195 + match self.client.view_notebook(&record.uri).await { 196 + Ok((notebook, entries)) => { 197 + let ident = record.uri.authority().clone().into_static(); 198 + let title = notebook 199 + .title 200 + .as_ref() 201 + .map(|t| SmolStr::new(t.as_ref())) 202 + .unwrap_or_else(|| SmolStr::new("Untitled")); 203 + 204 + let result = Arc::new((notebook, entries)); 205 + // Cache it 206 + cache_impl::insert(&self.book_cache, (ident, title), result.clone()); 207 + notebooks.push(result); 208 + } 209 + Err(_) => continue, // Skip notebooks that fail to load 210 + } 211 + } 212 + } 213 + 214 + Ok(notebooks) 97 215 } 98 216 99 217 pub async fn list_notebook_entries( ··· 101 219 ident: AtIdentifier<'static>, 102 220 book_title: SmolStr, 103 221 ) -> Result<Option<Vec<BookEntryView<'static>>>> { 104 - use weaver_common::view::view_entry; 105 - 106 222 if let Some(result) = self.get_notebook(ident.clone(), book_title).await? { 107 223 let (notebook, entries) = result.as_ref(); 108 224 let mut book_entries = Vec::new(); 109 225 110 226 for index in 0..entries.len() { 111 - match view_entry(self.client.clone(), notebook, entries, index).await { 227 + match self.client.view_entry(notebook, entries, index).await { 112 228 Ok(book_entry) => book_entries.push(book_entry), 113 229 Err(_) => continue, // Skip entries that fail to load 114 230 }
+7 -3
crates/weaver-app/src/main.rs
··· 4 4 #[allow(unused)] 5 5 use dioxus::{prelude::*, CapturedError}; 6 6 7 + #[cfg(all(feature = "fullstack-server", feature = "server"))] 8 + use dioxus::fullstack::response::Extension; 7 9 #[cfg(feature = "fullstack-server")] 8 - use dioxus::fullstack::{response::Extension, FullstackContext}; 10 + use dioxus::fullstack::FullstackContext; 9 11 #[allow(unused)] 10 12 use jacquard::{ 11 13 client::BasicClient, ··· 101 103 .merge(dioxus::server::router(App)) 102 104 }; 103 105 106 + let client = Arc::new(BasicClient::unauthenticated()); 107 + 104 108 #[cfg(feature = "fullstack-server")] 105 109 let router = { 106 - let fetcher = Arc::new(CachedFetcher::new(Arc::new(BasicClient::unauthenticated()))); 107 - let blob_cache = Arc::new(BlobCache::new(Arc::new(BasicClient::unauthenticated()))); 110 + let fetcher = Arc::new(CachedFetcher::new(client.clone())); 111 + let blob_cache = Arc::new(BlobCache::new(client.clone())); 108 112 dioxus::server::router(App).layer(middleware::from_fn({ 109 113 let fetcher = fetcher.clone(); 110 114 let blob_cache = blob_cache.clone();
+2
crates/weaver-app/src/service_worker.rs
··· 101 101 Ok(()) 102 102 } 103 103 104 + #[allow(unused)] 104 105 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 105 106 pub async fn register_service_worker() -> Result<(), String> { 106 107 Ok(()) 107 108 } 108 109 110 + #[allow(unused)] 109 111 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 110 112 pub fn send_blob_mappings( 111 113 _notebook: &str,
+24 -8
crates/weaver-app/src/views/home.rs
··· 7 7 #[component] 8 8 pub fn Home() -> Element { 9 9 let fetcher = use_context::<fetch::CachedFetcher>(); 10 - let notebooks = use_signal(|| fetcher.list_recent_notebooks()); 10 + 11 + // Fetch notebooks from UFOS 12 + let notebooks = use_resource(move || { 13 + let fetcher = fetcher.clone(); 14 + async move { fetcher.fetch_notebooks_from_ufos().await } 15 + }); 16 + 11 17 rsx! { 12 18 document::Link { rel: "stylesheet", href: NOTEBOOK_CARD_CSS } 13 19 14 20 div { class: "notebooks-list", 15 - for notebook in notebooks.iter() { 16 - { 17 - let view = &notebook.0; 18 - rsx! { 19 - div { 20 - key: "{view.cid}", 21 - NotebookCard { notebook: view.clone() } 21 + match notebooks() { 22 + Some(Ok(notebook_list)) => rsx! { 23 + for notebook in notebook_list.iter() { 24 + { 25 + let view = &notebook.0; 26 + rsx! { 27 + div { 28 + key: "{view.cid}", 29 + NotebookCard { notebook: view.clone() } 30 + } 31 + } 22 32 } 23 33 } 34 + }, 35 + Some(Err(_)) => rsx! { 36 + div { "Error loading notebooks" } 37 + }, 38 + None => rsx! { 39 + div { "Loading notebooks..." } 24 40 } 25 41 } 26 42 }
+41 -4
crates/weaver-app/src/views/navbar.rs
··· 11 11 /// routes will be rendered under the outlet inside this component 12 12 #[component] 13 13 pub fn Navbar() -> Element { 14 + let route = use_route::<Route>(); 15 + 14 16 rsx! { 15 17 document::Link { rel: "stylesheet", href: THEME_DEFAULTS_CSS } 16 18 document::Link { rel: "stylesheet", href: NAVBAR_CSS } 17 19 18 20 div { 19 21 id: "navbar", 20 - Link { 21 - to: Route::Home {}, 22 - "Home" 22 + nav { class: "breadcrumbs", 23 + Link { 24 + to: Route::Home {}, 25 + class: "breadcrumb", 26 + "Home" 27 + } 28 + 29 + // Show repository breadcrumb if we're on a repository page 30 + match route { 31 + Route::RepositoryIndex { ident } => rsx! { 32 + span { class: "breadcrumb-separator", " > " } 33 + span { class: "breadcrumb breadcrumb-current", "@{ident}" } 34 + }, 35 + Route::NotebookIndex { ident, book_title } => rsx! { 36 + span { class: "breadcrumb-separator", " > " } 37 + Link { 38 + to: Route::RepositoryIndex { ident: ident.clone() }, 39 + class: "breadcrumb", 40 + "@{ident}" 41 + } 42 + span { class: "breadcrumb-separator", " > " } 43 + span { class: "breadcrumb breadcrumb-current", "{book_title}" } 44 + }, 45 + Route::Entry { ident, book_title, .. } => rsx! { 46 + span { class: "breadcrumb-separator", " > " } 47 + Link { 48 + to: Route::RepositoryIndex { ident: ident.clone() }, 49 + class: "breadcrumb", 50 + "@{ident}" 51 + } 52 + span { class: "breadcrumb-separator", " > " } 53 + Link { 54 + to: Route::NotebookIndex { ident: ident.clone(), book_title: book_title.clone() }, 55 + class: "breadcrumb", 56 + "{book_title}" 57 + } 58 + }, 59 + _ => rsx! {} 60 + } 23 61 } 24 - 25 62 } 26 63 27 64 // The `Outlet` component is used to render the next component inside the layout. In this case, it will render either
+3 -1
crates/weaver-cli/src/main.rs
··· 9 9 use std::io::BufRead; 10 10 use std::path::PathBuf; 11 11 use std::sync::Arc; 12 + use weaver_common::normalize_title_path; 12 13 use weaver_renderer::atproto::AtProtoPreprocessContext; 13 14 use weaver_renderer::static_site::StaticSiteWriter; 14 15 use weaver_renderer::utils::VaultBrokenLinkCallback; ··· 206 207 tracing_subscriber::fmt() 207 208 .with_env_filter( 208 209 tracing_subscriber::EnvFilter::try_from_default_env() 209 - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("debug")) 210 + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("debug")), 210 211 ) 211 212 .init(); 212 213 ··· 379 380 let entry = Entry::new() 380 381 .content(output.as_str()) 381 382 .title(entry_title.as_ref()) 383 + .path(normalize_title_path(entry_title.as_ref())) 382 384 .created_at(Datetime::now()) 383 385 .maybe_embeds(embeds) 384 386 .build();
+767
crates/weaver-common/src/agent.rs
··· 1 + // Re-export view types for use elsewhere 2 + pub use weaver_api::sh_weaver::notebook::{ 3 + AuthorListView, BookEntryRef, BookEntryView, EntryView, NotebookView, 4 + }; 5 + 6 + // Re-export jacquard for convenience 7 + use crate::error::WeaverError; 8 + pub use jacquard; 9 + use jacquard::bytes::Bytes; 10 + use jacquard::client::{Agent, AgentError, AgentErrorKind, AgentSession, AgentSessionExt}; 11 + use jacquard::error::ClientError; 12 + use jacquard::prelude::*; 13 + use jacquard::types::blob::{BlobRef, MimeType}; 14 + use jacquard::types::string::{AtUri, Did, RecordKey}; 15 + use jacquard::types::tid::Tid; 16 + use jacquard::xrpc::Response; 17 + use jacquard::{IntoStatic, xrpc}; 18 + use mime_sniffer::MimeTypeSniffer; 19 + use std::path::Path; 20 + use weaver_api::com_atproto::repo::get_record::GetRecordResponse; 21 + use weaver_api::com_atproto::repo::strong_ref::StrongRef; 22 + use weaver_api::sh_weaver::notebook::entry; 23 + use weaver_api::sh_weaver::publish::blob::Blob as PublishedBlob; 24 + 25 + use crate::{PublishResult, W_TICKER, WeaverExt, normalize_title_path}; 26 + 27 + impl<A: AgentSession + IdentityResolver> WeaverExt for Agent<A> { 28 + async fn publish_notebook(&self, _path: &Path) -> Result<PublishResult<'_>, WeaverError> { 29 + // TODO: Implementation 30 + todo!("publish_notebook not yet implemented") 31 + } 32 + 33 + async fn publish_blob<'a>( 34 + &self, 35 + blob: Bytes, 36 + url_path: &'a str, 37 + prev: Option<Tid>, 38 + ) -> Result<(StrongRef<'a>, PublishedBlob<'a>), WeaverError> { 39 + let mime_type = 40 + MimeType::new_owned(blob.sniff_mime_type().unwrap_or("application/octet-stream")); 41 + 42 + let blob = self.upload_blob(blob, mime_type).await?; 43 + let publish_record = PublishedBlob::new() 44 + .path(url_path) 45 + .upload(BlobRef::Blob(blob)) 46 + .build(); 47 + let tid = W_TICKER.lock().await.next(prev); 48 + let record = self 49 + .create_record(publish_record.clone(), Some(RecordKey::any(tid.as_str())?)) 50 + .await?; 51 + let strong_ref = StrongRef::new().uri(record.uri).cid(record.cid).build(); 52 + 53 + Ok((strong_ref, publish_record)) 54 + } 55 + 56 + async fn upsert_notebook( 57 + &self, 58 + title: &str, 59 + author_did: &Did<'_>, 60 + ) -> Result<(AtUri<'static>, Vec<StrongRef<'static>>), WeaverError> { 61 + use jacquard::types::collection::Collection; 62 + use jacquard::types::nsid::Nsid; 63 + use jacquard::xrpc::XrpcExt; 64 + use weaver_api::com_atproto::repo::list_records::ListRecords; 65 + use weaver_api::sh_weaver::notebook::book::Book; 66 + 67 + // Find the PDS for this DID 68 + let pds_url = self.pds_for_did(author_did).await.map_err(|e| { 69 + AgentError::from(ClientError::from(e).with_context("Failed to resolve PDS for DID")) 70 + })?; 71 + 72 + // Search for existing notebook with this title 73 + let resp = self 74 + .xrpc(pds_url) 75 + .send( 76 + &ListRecords::new() 77 + .repo(author_did.clone()) 78 + .collection(Nsid::raw(Book::NSID)) 79 + .limit(100) 80 + .build(), 81 + ) 82 + .await 83 + .map_err(|e| AgentError::from(ClientError::from(e)))?; 84 + 85 + if let Ok(list) = resp.parse() { 86 + for record in list.records { 87 + let notebook: Book = jacquard::from_data(&record.value).map_err(|_| { 88 + AgentError::from(ClientError::invalid_request( 89 + "Failed to parse notebook record", 90 + )) 91 + })?; 92 + if let Some(book_title) = notebook.title 93 + && book_title == title 94 + { 95 + let entries = notebook 96 + .entry_list 97 + .iter() 98 + .cloned() 99 + .map(IntoStatic::into_static) 100 + .collect(); 101 + return Ok((record.uri.into_static(), entries)); 102 + } 103 + } 104 + } 105 + 106 + // Notebook doesn't exist, create it 107 + use weaver_api::sh_weaver::actor::Author; 108 + let path = normalize_title_path(title); 109 + let author = Author::new().did(author_did.clone()).build(); 110 + let book = Book::new() 111 + .authors(vec![author]) 112 + .entry_list(vec![]) 113 + .maybe_title(Some(title.into())) 114 + .maybe_path(Some(path.into())) 115 + .maybe_created_at(Some(jacquard::types::string::Datetime::now())) 116 + .build(); 117 + 118 + let response = self.create_record(book, None).await?; 119 + Ok((response.uri, Vec::new())) 120 + } 121 + 122 + async fn upsert_entry( 123 + &self, 124 + notebook_title: &str, 125 + entry_title: &str, 126 + entry: entry::Entry<'_>, 127 + ) -> Result<(AtUri<'static>, bool), WeaverError> { 128 + // Get our own DID 129 + let (did, _) = self.info().await.ok_or_else(|| { 130 + AgentError::from(ClientError::invalid_request("No session info available")) 131 + })?; 132 + 133 + // Find or create notebook 134 + let (notebook_uri, entry_refs) = self.upsert_notebook(notebook_title, &did).await?; 135 + 136 + // Check if entry with this title exists in the notebook 137 + for entry_ref in &entry_refs { 138 + let existing = self 139 + .get_record::<entry::Entry>(&entry_ref.uri) 140 + .await 141 + .map_err(|e| AgentError::from(ClientError::from(e)))?; 142 + if let Ok(existing_entry) = existing.parse() { 143 + if existing_entry.value.title == entry_title { 144 + // Update existing entry 145 + self.update_record::<entry::Entry>(&entry_ref.uri, |e| { 146 + e.content = entry.content.clone(); 147 + e.embeds = entry.embeds.clone(); 148 + e.tags = entry.tags.clone(); 149 + }) 150 + .await?; 151 + return Ok((entry_ref.uri.clone().into_static(), false)); 152 + } 153 + } 154 + } 155 + 156 + // Entry doesn't exist, create it 157 + let response = self.create_record(entry, None).await?; 158 + let entry_uri = response.uri.clone(); 159 + 160 + // Add to notebook's entry_list 161 + use weaver_api::sh_weaver::notebook::book::Book; 162 + let new_ref = StrongRef::new().uri(response.uri).cid(response.cid).build(); 163 + 164 + self.update_record::<Book>(&notebook_uri, |book| { 165 + book.entry_list.push(new_ref); 166 + }) 167 + .await?; 168 + 169 + Ok((entry_uri, true)) 170 + } 171 + 172 + async fn view_notebook( 173 + &self, 174 + uri: &AtUri<'_>, 175 + ) -> Result<(NotebookView<'static>, Vec<StrongRef<'static>>), WeaverError> { 176 + use jacquard::to_data; 177 + use weaver_api::sh_weaver::notebook::AuthorListView; 178 + use weaver_api::sh_weaver::notebook::book::Book; 179 + 180 + let notebook = self 181 + .get_record::<Book>(uri) 182 + .await 183 + .map_err(|e| AgentError::from(e))? 184 + .into_output() 185 + .map_err(|_| { 186 + AgentError::from(ClientError::invalid_request("Failed to parse Book record")) 187 + })?; 188 + 189 + let title = notebook.value.title.clone(); 190 + let tags = notebook.value.tags.clone(); 191 + 192 + let mut authors = Vec::new(); 193 + 194 + for (index, author) in notebook.value.authors.iter().enumerate() { 195 + let (profile_uri, profile_view) = self.hydrate_profile_view(&author.did).await?; 196 + authors.push( 197 + AuthorListView::new() 198 + .maybe_uri(profile_uri) 199 + .record(profile_view) 200 + .index(index as i64) 201 + .build(), 202 + ); 203 + } 204 + let entries = notebook 205 + .value 206 + .entry_list 207 + .iter() 208 + .cloned() 209 + .map(IntoStatic::into_static) 210 + .collect(); 211 + 212 + Ok(( 213 + NotebookView::new() 214 + .cid(notebook.cid.ok_or_else(|| { 215 + AgentError::from(ClientError::invalid_request("Notebook missing CID")) 216 + })?) 217 + .uri(notebook.uri) 218 + .indexed_at(jacquard::types::string::Datetime::now()) 219 + .maybe_title(title) 220 + .maybe_tags(tags) 221 + .authors(authors) 222 + .record(to_data(&notebook.value).map_err(|_| { 223 + AgentError::from(ClientError::invalid_request("Failed to serialize notebook")) 224 + })?) 225 + .build(), 226 + entries, 227 + )) 228 + } 229 + 230 + async fn fetch_entry_view<'a>( 231 + &self, 232 + notebook: &NotebookView<'a>, 233 + entry_ref: &StrongRef<'_>, 234 + ) -> Result<EntryView<'a>, WeaverError> { 235 + use jacquard::to_data; 236 + use weaver_api::sh_weaver::notebook::entry::Entry; 237 + 238 + let entry_uri = Entry::uri(entry_ref.uri.clone()) 239 + .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid entry URI")))?; 240 + let entry = self.fetch_record(&entry_uri).await?; 241 + 242 + let title = entry.value.title.clone(); 243 + let tags = entry.value.tags.clone(); 244 + 245 + Ok(EntryView::new() 246 + .cid(entry.cid.ok_or_else(|| { 247 + AgentError::from(ClientError::invalid_request("Entry missing CID")) 248 + })?) 249 + .uri(entry.uri) 250 + .indexed_at(jacquard::types::string::Datetime::now()) 251 + .record(to_data(&entry.value).map_err(|_| { 252 + AgentError::from(ClientError::invalid_request("Failed to serialize entry")) 253 + })?) 254 + .maybe_tags(tags) 255 + .title(title) 256 + .authors(notebook.authors.clone()) 257 + .build()) 258 + } 259 + 260 + async fn entry_by_title<'a>( 261 + &self, 262 + notebook: &NotebookView<'a>, 263 + entries: &[StrongRef<'_>], 264 + title: &str, 265 + ) -> Result<Option<(BookEntryView<'a>, entry::Entry<'a>)>, WeaverError> { 266 + use weaver_api::sh_weaver::notebook::BookEntryRef; 267 + use weaver_api::sh_weaver::notebook::entry::Entry; 268 + 269 + for (index, entry_ref) in entries.iter().enumerate() { 270 + let resp = self 271 + .get_record::<Entry>(&entry_ref.uri) 272 + .await 273 + .map_err(|e| AgentError::from(e))?; 274 + if let Ok(entry) = resp.parse() { 275 + if entry.value.path == title || entry.value.title == title { 276 + // Build BookEntryView with prev/next 277 + let entry_view = self.fetch_entry_view(notebook, entry_ref).await?; 278 + 279 + let prev_entry = if index > 0 { 280 + let prev_entry_ref = &entries[index - 1]; 281 + self.fetch_entry_view(notebook, prev_entry_ref).await.ok() 282 + } else { 283 + None 284 + } 285 + .map(|e| BookEntryRef::new().entry(e).build()); 286 + 287 + let next_entry = if index < entries.len() - 1 { 288 + let next_entry_ref = &entries[index + 1]; 289 + self.fetch_entry_view(notebook, next_entry_ref).await.ok() 290 + } else { 291 + None 292 + } 293 + .map(|e| BookEntryRef::new().entry(e).build()); 294 + 295 + let book_entry_view = BookEntryView::new() 296 + .entry(entry_view) 297 + .maybe_next(next_entry) 298 + .maybe_prev(prev_entry) 299 + .index(index as i64) 300 + .build(); 301 + 302 + return Ok(Some((book_entry_view, entry.value.into_static()))); 303 + } 304 + } 305 + } 306 + Ok(None) 307 + } 308 + 309 + async fn notebook_by_title( 310 + &self, 311 + ident: &jacquard::types::ident::AtIdentifier<'_>, 312 + title: &str, 313 + ) -> Result<Option<(NotebookView<'static>, Vec<StrongRef<'static>>)>, WeaverError> { 314 + use jacquard::types::collection::Collection; 315 + use jacquard::types::nsid::Nsid; 316 + use jacquard::xrpc::XrpcExt; 317 + use weaver_api::com_atproto::repo::list_records::ListRecords; 318 + use weaver_api::sh_weaver::notebook::AuthorListView; 319 + use weaver_api::sh_weaver::notebook::book::Book; 320 + 321 + let (repo_did, pds_url) = match ident { 322 + jacquard::types::ident::AtIdentifier::Did(did) => { 323 + let pds = self.pds_for_did(did).await.map_err(|e| { 324 + AgentError::from( 325 + ClientError::from(e).with_context("Failed to resolve PDS for DID"), 326 + ) 327 + })?; 328 + (did.clone(), pds) 329 + } 330 + jacquard::types::ident::AtIdentifier::Handle(handle) => { 331 + self.pds_for_handle(handle).await.map_err(|e| { 332 + AgentError::from(ClientError::from(e).with_context("Failed to resolve handle")) 333 + })? 334 + } 335 + }; 336 + 337 + // TODO: use the cursor to search through all records with this NSID for the repo 338 + let resp = self 339 + .xrpc(pds_url) 340 + .send( 341 + &ListRecords::new() 342 + .repo(repo_did) 343 + .collection(Nsid::raw(Book::NSID)) 344 + .limit(100) 345 + .build(), 346 + ) 347 + .await 348 + .map_err(|e| AgentError::from(ClientError::from(e)))?; 349 + 350 + if let Ok(list) = resp.parse() { 351 + for record in list.records { 352 + let notebook: Book = jacquard::from_data(&record.value).map_err(|_| { 353 + AgentError::from(ClientError::invalid_request( 354 + "Failed to parse notebook record", 355 + )) 356 + })?; 357 + if let Some(book_title) = notebook.path 358 + && book_title == title 359 + { 360 + let tags = notebook.tags.clone(); 361 + 362 + let mut authors = Vec::new(); 363 + 364 + for (index, author) in notebook.authors.iter().enumerate() { 365 + let (profile_uri, profile_view) = 366 + self.hydrate_profile_view(&author.did).await?; 367 + authors.push( 368 + AuthorListView::new() 369 + .maybe_uri(profile_uri) 370 + .record(profile_view) 371 + .index(index as i64) 372 + .build(), 373 + ); 374 + } 375 + let entries = notebook 376 + .entry_list 377 + .iter() 378 + .cloned() 379 + .map(IntoStatic::into_static) 380 + .collect(); 381 + 382 + return Ok(Some(( 383 + NotebookView::new() 384 + .cid(record.cid) 385 + .uri(record.uri) 386 + .indexed_at(jacquard::types::string::Datetime::now()) 387 + .title(book_title) 388 + .maybe_tags(tags) 389 + .authors(authors) 390 + .record(record.value.clone()) 391 + .build() 392 + .into_static(), 393 + entries, 394 + ))); 395 + } else if let Some(book_title) = notebook.title 396 + && book_title == title 397 + { 398 + let tags = notebook.tags.clone(); 399 + 400 + let mut authors = Vec::new(); 401 + 402 + for (index, author) in notebook.authors.iter().enumerate() { 403 + let (profile_uri, profile_view) = 404 + self.hydrate_profile_view(&author.did).await?; 405 + authors.push( 406 + AuthorListView::new() 407 + .maybe_uri(profile_uri) 408 + .record(profile_view) 409 + .index(index as i64) 410 + .build(), 411 + ); 412 + } 413 + let entries = notebook 414 + .entry_list 415 + .iter() 416 + .cloned() 417 + .map(IntoStatic::into_static) 418 + .collect(); 419 + 420 + return Ok(Some(( 421 + NotebookView::new() 422 + .cid(record.cid) 423 + .uri(record.uri) 424 + .indexed_at(jacquard::types::string::Datetime::now()) 425 + .title(book_title) 426 + .maybe_tags(tags) 427 + .authors(authors) 428 + .record(record.value.clone()) 429 + .build() 430 + .into_static(), 431 + entries, 432 + ))); 433 + } 434 + } 435 + } 436 + 437 + Ok(None) 438 + } 439 + 440 + async fn confirm_record_ref(&self, uri: &AtUri<'_>) -> Result<StrongRef<'_>, WeaverError> { 441 + let rkey = uri.rkey().ok_or_else(|| { 442 + AgentError::from( 443 + ClientError::invalid_request("AtUri missing rkey") 444 + .with_help("ensure the URI includes a record key after the collection"), 445 + ) 446 + })?; 447 + 448 + // Resolve authority (DID or handle) to get DID and PDS 449 + use jacquard::types::ident::AtIdentifier; 450 + let (repo_did, pds_url) = match uri.authority() { 451 + AtIdentifier::Did(did) => { 452 + let pds = self.pds_for_did(did).await.map_err(|e| { 453 + AgentError::from( 454 + ClientError::from(e) 455 + .with_context("DID document resolution failed during record retrieval"), 456 + ) 457 + })?; 458 + (did.clone(), pds) 459 + } 460 + AtIdentifier::Handle(handle) => self.pds_for_handle(handle).await.map_err(|e| { 461 + AgentError::from( 462 + ClientError::from(e) 463 + .with_context("handle resolution failed during record retrieval"), 464 + ) 465 + })?, 466 + }; 467 + 468 + // Make stateless XRPC call to that PDS (no auth required for public records) 469 + use weaver_api::com_atproto::repo::get_record::GetRecord; 470 + let request = GetRecord::new() 471 + .repo(AtIdentifier::Did(repo_did)) 472 + .collection( 473 + uri.collection() 474 + .expect("collection should exist if rkey does") 475 + .clone(), 476 + ) 477 + .rkey(rkey.clone()) 478 + .build(); 479 + 480 + let response: Response<GetRecordResponse> = { 481 + let http_request = xrpc::build_http_request(&pds_url, &request, &self.opts().await) 482 + .map_err(|e| AgentError::from(ClientError::transport(e)))?; 483 + 484 + let http_response = self 485 + .send_http(http_request) 486 + .await 487 + .map_err(|e| AgentError::from(ClientError::transport(e)))?; 488 + 489 + xrpc::process_response(http_response) 490 + } 491 + .map_err(|e| AgentError::new(AgentErrorKind::Client, Some(e.into())))?; 492 + let record = response.parse().map_err(|e| AgentError::xrpc(e))?; 493 + let strong_ref = StrongRef::new() 494 + .uri(record.uri) 495 + .cid(record.cid.expect("when does this NOT have a CID?")) 496 + .build(); 497 + Ok(strong_ref.into_static()) 498 + } 499 + 500 + async fn hydrate_profile_view( 501 + &self, 502 + did: &Did<'_>, 503 + ) -> Result< 504 + ( 505 + Option<AtUri<'static>>, 506 + weaver_api::sh_weaver::actor::ProfileDataView<'static>, 507 + ), 508 + WeaverError, 509 + > { 510 + use weaver_api::app_bsky::actor::{ 511 + ProfileViewDetailed, get_profile::GetProfile, profile::Profile as BskyProfile, 512 + }; 513 + use weaver_api::sh_weaver::actor::{ 514 + ProfileDataView, ProfileDataViewInner, ProfileView, profile::Profile as WeaverProfile, 515 + }; 516 + 517 + let handles = self.resolve_did_doc_owned(&did).await?.handles(); 518 + let handle = handles.first().ok_or_else(|| { 519 + AgentError::from(ClientError::invalid_request("couldn't resolve handle")) 520 + })?; 521 + 522 + // Try weaver profile first 523 + let weaver_uri = WeaverProfile::uri(format!("at://{}/sh.weaver.actor.profile/self", did)) 524 + .map_err(|_| { 525 + AgentError::from(ClientError::invalid_request("Invalid weaver profile URI")) 526 + })?; 527 + if let Ok(weaver_record) = self.fetch_record(&weaver_uri).await { 528 + // Convert blobs to CDN URLs 529 + let avatar = weaver_record 530 + .value 531 + .avatar 532 + .as_ref() 533 + .map(|blob| { 534 + let cid = blob.blob().cid(); 535 + jacquard::types::string::Uri::new_owned(format!( 536 + "https://cdn.bsky.app/img/avatar/plain/{}/{}", 537 + did, cid 538 + )) 539 + }) 540 + .transpose() 541 + .map_err(|_| { 542 + AgentError::from(ClientError::invalid_request("Invalid avatar URI")) 543 + })?; 544 + let banner = weaver_record 545 + .value 546 + .banner 547 + .as_ref() 548 + .map(|blob| { 549 + let cid = blob.blob().cid(); 550 + jacquard::types::string::Uri::new_owned(format!( 551 + "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}", 552 + did, cid 553 + )) 554 + }) 555 + .transpose() 556 + .map_err(|_| { 557 + AgentError::from(ClientError::invalid_request("Invalid banner URI")) 558 + })?; 559 + 560 + let profile_view = ProfileView::new() 561 + .did(did.clone()) 562 + .handle(handle.clone()) 563 + .maybe_display_name(weaver_record.value.display_name.clone()) 564 + .maybe_description(weaver_record.value.description.clone()) 565 + .maybe_avatar(avatar) 566 + .maybe_banner(banner) 567 + .maybe_location(weaver_record.value.location.clone()) 568 + .maybe_links(weaver_record.value.links.clone()) 569 + .maybe_pronouns(weaver_record.value.pronouns.clone()) 570 + .maybe_pinned(weaver_record.value.pinned.clone()) 571 + .indexed_at(jacquard::types::string::Datetime::now()) 572 + .maybe_created_at(weaver_record.value.created_at) 573 + .build(); 574 + 575 + return Ok(( 576 + Some(weaver_uri.as_uri().clone().into_static()), 577 + ProfileDataView::new() 578 + .inner(ProfileDataViewInner::ProfileView(Box::new(profile_view))) 579 + .build() 580 + .into_static(), 581 + )); 582 + } 583 + 584 + if let Ok(bsky_resp) = self 585 + .send(GetProfile::new().actor(did.clone()).build()) 586 + .await 587 + { 588 + if let Ok(output) = bsky_resp.parse() { 589 + let bsky_uri = BskyProfile::uri(format!( 590 + "at://{}/app.bsky.actor.profile/self", 591 + did 592 + )) 593 + .map_err(|_| { 594 + AgentError::from(ClientError::invalid_request("Invalid bsky profile URI")) 595 + })?; 596 + return Ok(( 597 + Some(bsky_uri.as_uri().clone().into_static()), 598 + ProfileDataView::new() 599 + .inner(ProfileDataViewInner::ProfileViewDetailed(Box::new( 600 + output.value.into_static(), 601 + ))) 602 + .build() 603 + .into_static(), 604 + )); 605 + } 606 + } 607 + 608 + // Fallback: fetch bsky profile record directly and construct minimal ProfileViewDetailed 609 + let bsky_uri = BskyProfile::uri(format!("at://{}/app.bsky.actor.profile/self", did)) 610 + .map_err(|_| { 611 + AgentError::from(ClientError::invalid_request("Invalid bsky profile URI")) 612 + })?; 613 + let bsky_record = self.fetch_record(&bsky_uri).await?; 614 + 615 + let avatar = bsky_record 616 + .value 617 + .avatar 618 + .as_ref() 619 + .map(|blob| { 620 + let cid = blob.blob().cid(); 621 + jacquard::types::string::Uri::new_owned(format!( 622 + "https://cdn.bsky.app/img/avatar/plain/{}/{}", 623 + did, cid 624 + )) 625 + }) 626 + .transpose() 627 + .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid avatar URI")))?; 628 + let banner = bsky_record 629 + .value 630 + .banner 631 + .as_ref() 632 + .map(|blob| { 633 + let cid = blob.blob().cid(); 634 + jacquard::types::string::Uri::new_owned(format!( 635 + "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}", 636 + did, cid 637 + )) 638 + }) 639 + .transpose() 640 + .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid banner URI")))?; 641 + 642 + let profile_detailed = ProfileViewDetailed::new() 643 + .did(did.clone()) 644 + .handle(handle.clone()) 645 + .maybe_display_name(bsky_record.value.display_name.clone()) 646 + .maybe_description(bsky_record.value.description.clone()) 647 + .maybe_avatar(avatar) 648 + .maybe_banner(banner) 649 + .indexed_at(jacquard::types::string::Datetime::now()) 650 + .maybe_created_at(bsky_record.value.created_at) 651 + .build(); 652 + 653 + Ok(( 654 + Some(bsky_uri.as_uri().clone().into_static()), 655 + ProfileDataView::new() 656 + .inner(ProfileDataViewInner::ProfileViewDetailed(Box::new( 657 + profile_detailed, 658 + ))) 659 + .build() 660 + .into_static(), 661 + )) 662 + } 663 + 664 + async fn view_entry<'a>( 665 + &self, 666 + notebook: &NotebookView<'a>, 667 + entries: &[StrongRef<'_>], 668 + index: usize, 669 + ) -> Result<BookEntryView<'a>, WeaverError> { 670 + use weaver_api::sh_weaver::notebook::BookEntryRef; 671 + 672 + let entry_ref = entries 673 + .get(index) 674 + .ok_or_else(|| AgentError::from(ClientError::invalid_request("entry out of bounds")))?; 675 + let entry = self.fetch_entry_view(notebook, entry_ref).await?; 676 + 677 + let prev_entry = if index > 0 { 678 + let prev_entry_ref = &entries[index - 1]; 679 + self.fetch_entry_view(notebook, prev_entry_ref).await.ok() 680 + } else { 681 + None 682 + } 683 + .map(|e| BookEntryRef::new().entry(e).build()); 684 + 685 + let next_entry = if index < entries.len() - 1 { 686 + let next_entry_ref = &entries[index + 1]; 687 + self.fetch_entry_view(notebook, next_entry_ref).await.ok() 688 + } else { 689 + None 690 + } 691 + .map(|e| BookEntryRef::new().entry(e).build()); 692 + 693 + Ok(BookEntryView::new() 694 + .entry(entry) 695 + .maybe_next(next_entry) 696 + .maybe_prev(prev_entry) 697 + .index(index as i64) 698 + .build()) 699 + } 700 + 701 + async fn view_page<'a>( 702 + &self, 703 + notebook: &NotebookView<'a>, 704 + pages: &[StrongRef<'_>], 705 + index: usize, 706 + ) -> Result<BookEntryView<'a>, WeaverError> { 707 + use weaver_api::sh_weaver::notebook::BookEntryRef; 708 + 709 + let entry_ref = pages 710 + .get(index) 711 + .ok_or_else(|| AgentError::from(ClientError::invalid_request("entry out of bounds")))?; 712 + let entry = self.fetch_page_view(notebook, entry_ref).await?; 713 + 714 + let prev_entry = if index > 0 { 715 + let prev_entry_ref = &pages[index - 1]; 716 + self.fetch_page_view(notebook, prev_entry_ref).await.ok() 717 + } else { 718 + None 719 + } 720 + .map(|e| BookEntryRef::new().entry(e).build()); 721 + 722 + let next_entry = if index < pages.len() - 1 { 723 + let next_entry_ref = &pages[index + 1]; 724 + self.fetch_page_view(notebook, next_entry_ref).await.ok() 725 + } else { 726 + None 727 + } 728 + .map(|e| BookEntryRef::new().entry(e).build()); 729 + 730 + Ok(BookEntryView::new() 731 + .entry(entry) 732 + .maybe_next(next_entry) 733 + .maybe_prev(prev_entry) 734 + .index(index as i64) 735 + .build()) 736 + } 737 + 738 + async fn fetch_page_view<'a>( 739 + &self, 740 + notebook: &NotebookView<'a>, 741 + entry_ref: &StrongRef<'_>, 742 + ) -> Result<EntryView<'a>, WeaverError> { 743 + use jacquard::to_data; 744 + use weaver_api::sh_weaver::notebook::page::Page; 745 + 746 + let entry_uri = Page::uri(entry_ref.uri.clone()) 747 + .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid page URI")))?; 748 + let entry = self.fetch_record(&entry_uri).await?; 749 + 750 + let title = entry.value.title.clone(); 751 + let tags = entry.value.tags.clone(); 752 + 753 + Ok(EntryView::new() 754 + .cid(entry.cid.ok_or_else(|| { 755 + AgentError::from(ClientError::invalid_request("Page missing CID")) 756 + })?) 757 + .uri(entry.uri) 758 + .indexed_at(jacquard::types::string::Datetime::now()) 759 + .record(to_data(&entry.value).map_err(|_| { 760 + AgentError::from(ClientError::invalid_request("Failed to serialize page")) 761 + })?) 762 + .maybe_tags(tags) 763 + .title(title) 764 + .authors(notebook.authors.clone()) 765 + .build()) 766 + } 767 + }
+8 -2
crates/weaver-common/src/error.rs
··· 12 12 #[diagnostic_source] 13 13 Agent(#[from] jacquard::client::error::AgentError), 14 14 15 + /// Jacquard Identity resolution error 16 + #[error(transparent)] 17 + #[diagnostic_source] 18 + Identity(#[from] jacquard::identity::resolver::IdentityError), 19 + 15 20 /// Invalid notebook structure 16 21 #[error("invalid notebook structure: {0}")] 17 22 InvalidNotebook(String), ··· 49 54 50 55 /// Parse error with source code location information 51 56 #[derive(thiserror::Error, Debug, Diagnostic)] 52 - #[error("parse error")] 53 - #[diagnostic()] 57 + #[error("parse error: {}",self.kind)] 58 + #[diagnostic(code(weaver::parse))] 59 + 54 60 pub struct ParseError { 55 61 #[diagnostic_source] 56 62 kind: ParseErrorKind,
+45 -471
crates/weaver-common/src/lib.rs
··· 1 1 //! Weaver common library - thin wrapper around jacquard with notebook-specific conveniences 2 2 3 + pub mod agent; 3 4 pub mod constellation; 4 5 pub mod error; 5 - pub mod view; 6 6 pub mod worker_rt; 7 7 8 8 // Re-export jacquard for convenience 9 + pub use error::WeaverError; 9 10 pub use jacquard; 10 - use jacquard::error::ClientError; 11 + use jacquard::CowStr; 12 + use jacquard::bytes::Bytes; 13 + use jacquard::client::{Agent, AgentSession, AgentSessionExt}; 14 + use jacquard::prelude::*; 11 15 use jacquard::types::ident::AtIdentifier; 12 - use jacquard::{CowStr, IntoStatic, xrpc}; 13 - 14 - pub use error::WeaverError; 16 + use jacquard::types::string::{AtUri, Cid, Did, Handle}; 15 17 use jacquard::types::tid::{Ticker, Tid}; 16 - 17 - use jacquard::bytes::Bytes; 18 - use jacquard::client::{Agent, AgentError, AgentErrorKind, AgentSession, AgentSessionExt}; 19 - use jacquard::prelude::*; 20 - use jacquard::types::blob::{BlobRef, MimeType}; 21 - use jacquard::types::string::{AtUri, Cid, Did, Handle, RecordKey}; 22 - use jacquard::xrpc::Response; 23 - use mime_sniffer::MimeTypeSniffer; 24 18 use std::path::Path; 25 19 use std::sync::LazyLock; 26 20 use tokio::sync::Mutex; 27 - use weaver_api::com_atproto::repo::get_record::GetRecordResponse; 28 21 use weaver_api::com_atproto::repo::strong_ref::StrongRef; 29 22 use weaver_api::sh_weaver::notebook::entry; 30 23 use weaver_api::sh_weaver::publish::blob::Blob as PublishedBlob; ··· 109 102 fn view_notebook( 110 103 &self, 111 104 uri: &AtUri<'_>, 112 - ) -> impl Future<Output = Result<(view::NotebookView<'static>, Vec<StrongRef<'static>>), WeaverError>>; 105 + ) -> impl Future<Output = Result<(agent::NotebookView<'static>, Vec<StrongRef<'static>>), WeaverError>>; 113 106 114 107 /// Fetch an entry and construct EntryView 115 108 fn fetch_entry_view<'a>( 116 109 &self, 117 - notebook: &view::NotebookView<'a>, 110 + notebook: &agent::NotebookView<'a>, 118 111 entry_ref: &StrongRef<'_>, 119 - ) -> impl Future<Output = Result<view::EntryView<'a>, WeaverError>>; 112 + ) -> impl Future<Output = Result<agent::EntryView<'a>, WeaverError>>; 120 113 121 114 /// Search for an entry by title within a notebook's entry list 122 115 fn entry_by_title<'a>( 123 116 &self, 124 - notebook: &view::NotebookView<'a>, 117 + notebook: &agent::NotebookView<'a>, 125 118 entries: &[StrongRef<'_>], 126 119 title: &str, 127 - ) -> impl Future<Output = Result<Option<(view::BookEntryView<'a>, entry::Entry<'a>)>, WeaverError>>; 120 + ) -> impl Future<Output = Result<Option<(agent::BookEntryView<'a>, entry::Entry<'a>)>, WeaverError>>; 128 121 129 122 /// Search for a notebook by title for a given DID or handle 130 123 fn notebook_by_title( ··· 133 126 title: &str, 134 127 ) -> impl Future< 135 128 Output = Result< 136 - Option<(view::NotebookView<'static>, Vec<StrongRef<'static>>)>, 129 + Option<(agent::NotebookView<'static>, Vec<StrongRef<'static>>)>, 137 130 WeaverError, 138 131 >, 139 132 >; 140 - } 141 133 142 - impl<A: AgentSession + IdentityResolver> WeaverExt for Agent<A> { 143 - async fn publish_notebook(&self, _path: &Path) -> Result<PublishResult<'_>, WeaverError> { 144 - // TODO: Implementation 145 - todo!("publish_notebook not yet implemented") 146 - } 147 - 148 - async fn publish_blob<'a>( 134 + /// Hydrate a profile view from either weaver or bsky profile 135 + fn hydrate_profile_view( 149 136 &self, 150 - blob: Bytes, 151 - url_path: &'a str, 152 - prev: Option<Tid>, 153 - ) -> Result<(StrongRef<'a>, PublishedBlob<'a>), WeaverError> { 154 - let mime_type = 155 - MimeType::new_owned(blob.sniff_mime_type().unwrap_or("application/octet-stream")); 156 - 157 - let blob = self.upload_blob(blob, mime_type).await?; 158 - let publish_record = PublishedBlob::new() 159 - .path(url_path) 160 - .upload(BlobRef::Blob(blob)) 161 - .build(); 162 - let tid = W_TICKER.lock().await.next(prev); 163 - let record = self 164 - .create_record(publish_record.clone(), Some(RecordKey::any(tid.as_str())?)) 165 - .await?; 166 - let strong_ref = StrongRef::new().uri(record.uri).cid(record.cid).build(); 167 - 168 - Ok((strong_ref, publish_record)) 169 - } 170 - 171 - async fn upsert_notebook( 172 - &self, 173 - title: &str, 174 - author_did: &Did<'_>, 175 - ) -> Result<(AtUri<'static>, Vec<StrongRef<'static>>), WeaverError> { 176 - use jacquard::types::collection::Collection; 177 - use jacquard::types::nsid::Nsid; 178 - use jacquard::xrpc::XrpcExt; 179 - use weaver_api::com_atproto::repo::list_records::ListRecords; 180 - use weaver_api::sh_weaver::notebook::book::Book; 181 - 182 - // Find the PDS for this DID 183 - let pds_url = self.pds_for_did(author_did).await.map_err(|e| { 184 - AgentError::from(ClientError::from(e).with_context("Failed to resolve PDS for DID")) 185 - })?; 186 - 187 - // Search for existing notebook with this title 188 - let resp = self 189 - .xrpc(pds_url) 190 - .send( 191 - &ListRecords::new() 192 - .repo(author_did.clone()) 193 - .collection(Nsid::raw(Book::NSID)) 194 - .limit(100) 195 - .build(), 196 - ) 197 - .await 198 - .map_err(|e| AgentError::from(ClientError::from(e)))?; 199 - 200 - if let Ok(list) = resp.parse() { 201 - for record in list.records { 202 - let notebook: Book = jacquard::from_data(&record.value).map_err(|_| { 203 - AgentError::from(ClientError::invalid_request( 204 - "Failed to parse notebook record", 205 - )) 206 - })?; 207 - if let Some(book_title) = notebook.title 208 - && book_title == title 209 - { 210 - let entries = notebook 211 - .entry_list 212 - .iter() 213 - .cloned() 214 - .map(IntoStatic::into_static) 215 - .collect(); 216 - return Ok((record.uri.into_static(), entries)); 217 - } 218 - } 219 - } 220 - 221 - // Notebook doesn't exist, create it 222 - use weaver_api::sh_weaver::actor::Author; 223 - let author = Author::new().did(author_did.clone()).build(); 224 - let book = Book::new() 225 - .authors(vec![author]) 226 - .entry_list(vec![]) 227 - .maybe_title(Some(title.into())) 228 - .maybe_created_at(Some(jacquard::types::string::Datetime::now())) 229 - .build(); 230 - 231 - let response = self.create_record(book, None).await?; 232 - Ok((response.uri, Vec::new())) 233 - } 137 + did: &Did<'_>, 138 + ) -> impl Future< 139 + Output = Result< 140 + ( 141 + Option<AtUri<'static>>, 142 + weaver_api::sh_weaver::actor::ProfileDataView<'static>, 143 + ), 144 + WeaverError, 145 + >, 146 + >; 234 147 235 - async fn upsert_entry( 148 + /// View an entry at a specific index with prev/next navigation 149 + fn view_entry<'a>( 236 150 &self, 237 - notebook_title: &str, 238 - entry_title: &str, 239 - entry: entry::Entry<'_>, 240 - ) -> Result<(AtUri<'static>, bool), WeaverError> { 241 - // Get our own DID 242 - let (did, _) = self.info().await.ok_or_else(|| { 243 - AgentError::from(ClientError::invalid_request("No session info available")) 244 - })?; 151 + notebook: &agent::NotebookView<'a>, 152 + entries: &[StrongRef<'_>], 153 + index: usize, 154 + ) -> impl Future<Output = Result<agent::BookEntryView<'a>, WeaverError>>; 245 155 246 - // Find or create notebook 247 - let (notebook_uri, entry_refs) = self.upsert_notebook(notebook_title, &did).await?; 248 - 249 - // Check if entry with this title exists in the notebook 250 - for entry_ref in &entry_refs { 251 - let existing = self 252 - .get_record::<entry::Entry>(&entry_ref.uri) 253 - .await 254 - .map_err(|e| AgentError::from(ClientError::from(e)))?; 255 - if let Ok(existing_entry) = existing.parse() { 256 - if existing_entry.value.title == entry_title { 257 - // Update existing entry 258 - self.update_record::<entry::Entry>(&entry_ref.uri, |e| { 259 - e.content = entry.content.clone(); 260 - e.embeds = entry.embeds.clone(); 261 - e.tags = entry.tags.clone(); 262 - }) 263 - .await?; 264 - return Ok((entry_ref.uri.clone().into_static(), false)); 265 - } 266 - } 267 - } 268 - 269 - // Entry doesn't exist, create it 270 - let response = self.create_record(entry, None).await?; 271 - let entry_uri = response.uri.clone(); 272 - 273 - // Add to notebook's entry_list 274 - use weaver_api::sh_weaver::notebook::book::Book; 275 - let new_ref = StrongRef::new().uri(response.uri).cid(response.cid).build(); 276 - 277 - self.update_record::<Book>(&notebook_uri, |book| { 278 - book.entry_list.push(new_ref); 279 - }) 280 - .await?; 281 - 282 - Ok((entry_uri, true)) 283 - } 284 - 285 - async fn view_notebook( 156 + /// View a page at a specific index with prev/next navigation 157 + fn view_page<'a>( 286 158 &self, 287 - uri: &AtUri<'_>, 288 - ) -> Result<(view::NotebookView<'static>, Vec<StrongRef<'static>>), WeaverError> { 289 - use jacquard::to_data; 290 - use weaver_api::app_bsky::actor::profile::Profile as BskyProfile; 291 - use weaver_api::sh_weaver::notebook::AuthorListView; 292 - use weaver_api::sh_weaver::notebook::book::Book; 293 - 294 - let notebook = self 295 - .get_record::<Book>(uri) 296 - .await 297 - .map_err(|e| AgentError::from(e))? 298 - .into_output() 299 - .map_err(|_| { 300 - AgentError::from(ClientError::invalid_request("Failed to parse Book record")) 301 - })?; 302 - 303 - let title = notebook.value.title.clone(); 304 - let tags = notebook.value.tags.clone(); 305 - 306 - let mut authors = Vec::new(); 307 - 308 - for (index, author) in notebook.value.authors.iter().enumerate() { 309 - let author_uri = 310 - BskyProfile::uri(format!("at://{}/app.bsky.actor.profile/self", author.did)) 311 - .map_err(|_| { 312 - AgentError::from(ClientError::invalid_request("Invalid author profile URI")) 313 - })?; 314 - let author_profile = self.fetch_record(&author_uri).await?; 315 - 316 - authors.push( 317 - AuthorListView::new() 318 - .uri(author_uri.as_uri().clone()) 319 - .record(to_data(&author_profile).map_err(|_| { 320 - AgentError::from(ClientError::invalid_request( 321 - "Failed to serialize author profile", 322 - )) 323 - })?) 324 - .index(index as i64) 325 - .build(), 326 - ); 327 - } 328 - let entries = notebook 329 - .value 330 - .entry_list 331 - .iter() 332 - .cloned() 333 - .map(IntoStatic::into_static) 334 - .collect(); 335 - 336 - Ok(( 337 - view::NotebookView::new() 338 - .cid(notebook.cid.ok_or_else(|| { 339 - AgentError::from(ClientError::invalid_request("Notebook missing CID")) 340 - })?) 341 - .uri(notebook.uri) 342 - .indexed_at(jacquard::types::string::Datetime::now()) 343 - .maybe_title(title) 344 - .maybe_tags(tags) 345 - .authors(authors) 346 - .record(to_data(&notebook.value).map_err(|_| { 347 - AgentError::from(ClientError::invalid_request("Failed to serialize notebook")) 348 - })?) 349 - .build(), 350 - entries, 351 - )) 352 - } 159 + notebook: &agent::NotebookView<'a>, 160 + pages: &[StrongRef<'_>], 161 + index: usize, 162 + ) -> impl Future<Output = Result<agent::BookEntryView<'a>, WeaverError>>; 353 163 354 - async fn fetch_entry_view<'a>( 164 + /// Fetch a page view (like fetch_entry_view but for pages) 165 + fn fetch_page_view<'a>( 355 166 &self, 356 - notebook: &view::NotebookView<'a>, 167 + notebook: &agent::NotebookView<'a>, 357 168 entry_ref: &StrongRef<'_>, 358 - ) -> Result<view::EntryView<'a>, WeaverError> { 359 - use jacquard::to_data; 360 - use weaver_api::sh_weaver::notebook::entry::Entry; 361 - 362 - let entry_uri = Entry::uri(entry_ref.uri.clone()) 363 - .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid entry URI")))?; 364 - let entry = self.fetch_record(&entry_uri).await?; 365 - 366 - let title = entry.value.title.clone(); 367 - let tags = entry.value.tags.clone(); 368 - 369 - Ok(view::EntryView::new() 370 - .cid(entry.cid.ok_or_else(|| { 371 - AgentError::from(ClientError::invalid_request("Entry missing CID")) 372 - })?) 373 - .uri(entry.uri) 374 - .indexed_at(jacquard::types::string::Datetime::now()) 375 - .record(to_data(&entry.value).map_err(|_| { 376 - AgentError::from(ClientError::invalid_request("Failed to serialize entry")) 377 - })?) 378 - .maybe_tags(tags) 379 - .title(title) 380 - .authors(notebook.authors.clone()) 381 - .build()) 382 - } 383 - 384 - async fn entry_by_title<'a>( 385 - &self, 386 - notebook: &view::NotebookView<'a>, 387 - entries: &[StrongRef<'_>], 388 - title: &str, 389 - ) -> Result<Option<(view::BookEntryView<'a>, entry::Entry<'a>)>, WeaverError> { 390 - use weaver_api::sh_weaver::notebook::BookEntryRef; 391 - use weaver_api::sh_weaver::notebook::entry::Entry; 392 - 393 - for (index, entry_ref) in entries.iter().enumerate() { 394 - let resp = self 395 - .get_record::<Entry>(&entry_ref.uri) 396 - .await 397 - .map_err(|e| AgentError::from(e))?; 398 - if let Ok(entry) = resp.parse() { 399 - if entry.value.title == title { 400 - // Build BookEntryView with prev/next 401 - let entry_view = self.fetch_entry_view(notebook, entry_ref).await?; 402 - 403 - let prev_entry = if index > 0 { 404 - let prev_entry_ref = &entries[index - 1]; 405 - self.fetch_entry_view(notebook, prev_entry_ref).await.ok() 406 - } else { 407 - None 408 - } 409 - .map(|e| BookEntryRef::new().entry(e).build()); 410 - 411 - let next_entry = if index < entries.len() - 1 { 412 - let next_entry_ref = &entries[index + 1]; 413 - self.fetch_entry_view(notebook, next_entry_ref).await.ok() 414 - } else { 415 - None 416 - } 417 - .map(|e| BookEntryRef::new().entry(e).build()); 418 - 419 - let book_entry_view = view::BookEntryView::new() 420 - .entry(entry_view) 421 - .maybe_next(next_entry) 422 - .maybe_prev(prev_entry) 423 - .index(index as i64) 424 - .build(); 425 - 426 - return Ok(Some((book_entry_view, entry.value.into_static()))); 427 - } 428 - } 429 - } 430 - Ok(None) 431 - } 432 - 433 - async fn notebook_by_title( 434 - &self, 435 - ident: &jacquard::types::ident::AtIdentifier<'_>, 436 - title: &str, 437 - ) -> Result<Option<(view::NotebookView<'static>, Vec<StrongRef<'static>>)>, WeaverError> { 438 - use jacquard::to_data; 439 - use jacquard::types::collection::Collection; 440 - use jacquard::types::nsid::Nsid; 441 - use jacquard::xrpc::XrpcExt; 442 - use weaver_api::app_bsky::actor::profile::Profile as BskyProfile; 443 - use weaver_api::com_atproto::repo::list_records::ListRecords; 444 - use weaver_api::sh_weaver::notebook::AuthorListView; 445 - use weaver_api::sh_weaver::notebook::book::Book; 446 - 447 - let (repo_did, pds_url) = match ident { 448 - jacquard::types::ident::AtIdentifier::Did(did) => { 449 - let pds = self.pds_for_did(did).await.map_err(|e| { 450 - AgentError::from( 451 - ClientError::from(e).with_context("Failed to resolve PDS for DID"), 452 - ) 453 - })?; 454 - (did.clone(), pds) 455 - } 456 - jacquard::types::ident::AtIdentifier::Handle(handle) => { 457 - self.pds_for_handle(handle).await.map_err(|e| { 458 - AgentError::from(ClientError::from(e).with_context("Failed to resolve handle")) 459 - })? 460 - } 461 - }; 462 - 463 - // TODO: use the cursor to search through all records with this NSID for the repo 464 - let resp = self 465 - .xrpc(pds_url) 466 - .send( 467 - &ListRecords::new() 468 - .repo(repo_did) 469 - .collection(Nsid::raw(Book::NSID)) 470 - .limit(100) 471 - .build(), 472 - ) 473 - .await 474 - .map_err(|e| AgentError::from(ClientError::from(e)))?; 475 - 476 - if let Ok(list) = resp.parse() { 477 - for record in list.records { 478 - let notebook: Book = jacquard::from_data(&record.value).map_err(|_| { 479 - AgentError::from(ClientError::invalid_request( 480 - "Failed to parse notebook record", 481 - )) 482 - })?; 483 - if let Some(book_title) = notebook.title 484 - && book_title == title 485 - { 486 - let tags = notebook.tags.clone(); 487 - 488 - let mut authors = Vec::new(); 489 - 490 - for (index, author) in notebook.authors.iter().enumerate() { 491 - let author_uri = BskyProfile::uri(format!( 492 - "at://{}/app.bsky.actor.profile/self", 493 - author.did 494 - )) 495 - .map_err(|_| { 496 - AgentError::from(ClientError::invalid_request( 497 - "Invalid author profile URI", 498 - )) 499 - })?; 500 - let author_profile = self.fetch_record(&author_uri).await?; 501 - 502 - authors.push( 503 - AuthorListView::new() 504 - .uri(author_uri.as_uri().clone()) 505 - .record(to_data(&author_profile).map_err(|_| { 506 - AgentError::from(ClientError::invalid_request( 507 - "Failed to serialize author profile", 508 - )) 509 - })?) 510 - .index(index as i64) 511 - .build(), 512 - ); 513 - } 514 - let entries = notebook 515 - .entry_list 516 - .iter() 517 - .cloned() 518 - .map(IntoStatic::into_static) 519 - .collect(); 520 - 521 - return Ok(Some(( 522 - view::NotebookView::new() 523 - .cid(record.cid) 524 - .uri(record.uri) 525 - .indexed_at(jacquard::types::string::Datetime::now()) 526 - .title(book_title) 527 - .maybe_tags(tags) 528 - .authors(authors) 529 - .record(record.value.clone()) 530 - .build() 531 - .into_static(), 532 - entries, 533 - ))); 534 - } 535 - } 536 - } 537 - 538 - Ok(None) 539 - } 540 - 541 - async fn confirm_record_ref(&self, uri: &AtUri<'_>) -> Result<StrongRef<'_>, WeaverError> { 542 - let rkey = uri.rkey().ok_or_else(|| { 543 - AgentError::from( 544 - ClientError::invalid_request("AtUri missing rkey") 545 - .with_help("ensure the URI includes a record key after the collection"), 546 - ) 547 - })?; 548 - 549 - // Resolve authority (DID or handle) to get DID and PDS 550 - use jacquard::types::ident::AtIdentifier; 551 - let (repo_did, pds_url) = match uri.authority() { 552 - AtIdentifier::Did(did) => { 553 - let pds = self.pds_for_did(did).await.map_err(|e| { 554 - AgentError::from( 555 - ClientError::from(e) 556 - .with_context("DID document resolution failed during record retrieval"), 557 - ) 558 - })?; 559 - (did.clone(), pds) 560 - } 561 - AtIdentifier::Handle(handle) => self.pds_for_handle(handle).await.map_err(|e| { 562 - AgentError::from( 563 - ClientError::from(e) 564 - .with_context("handle resolution failed during record retrieval"), 565 - ) 566 - })?, 567 - }; 568 - 569 - // Make stateless XRPC call to that PDS (no auth required for public records) 570 - use weaver_api::com_atproto::repo::get_record::GetRecord; 571 - let request = GetRecord::new() 572 - .repo(AtIdentifier::Did(repo_did)) 573 - .collection( 574 - uri.collection() 575 - .expect("collection should exist if rkey does") 576 - .clone(), 577 - ) 578 - .rkey(rkey.clone()) 579 - .build(); 580 - 581 - let response: Response<GetRecordResponse> = { 582 - let http_request = xrpc::build_http_request(&pds_url, &request, &self.opts().await) 583 - .map_err(|e| AgentError::from(ClientError::transport(e)))?; 584 - 585 - let http_response = self 586 - .send_http(http_request) 587 - .await 588 - .map_err(|e| AgentError::from(ClientError::transport(e)))?; 589 - 590 - xrpc::process_response(http_response) 591 - } 592 - .map_err(|e| AgentError::new(AgentErrorKind::Client, Some(e.into())))?; 593 - let record = response.parse().map_err(|e| AgentError::xrpc(e))?; 594 - let strong_ref = StrongRef::new() 595 - .uri(record.uri) 596 - .cid(record.cid.expect("when does this NOT have a CID?")) 597 - .build(); 598 - Ok(strong_ref.into_static()) 599 - } 169 + ) -> impl Future<Output = Result<agent::EntryView<'a>, WeaverError>>; 600 170 } 601 171 602 172 /// Result of publishing a notebook ··· 771 341 LinkUri::Path(markdown_weaver::CowStr::Borrowed(dest_url)) 772 342 } 773 343 } 344 + 345 + pub fn normalize_title_path(title: &str) -> String { 346 + title.replace(' ', "_").to_lowercase() 347 + }
-286
crates/weaver-common/src/view.rs
··· 1 - use std::sync::Arc; 2 - 3 - use jacquard::{ 4 - IntoStatic, 5 - client::{AgentSessionExt, BasicClient}, 6 - from_data, 7 - prelude::IdentityResolver, 8 - to_data, 9 - types::{ 10 - aturi::AtUri, collection::Collection, ident::AtIdentifier, nsid::Nsid, 11 - string::Datetime, 12 - }, 13 - xrpc::XrpcExt, 14 - }; 15 - use miette::{IntoDiagnostic, Result}; 16 - use weaver_api::{ 17 - app_bsky::actor::profile::Profile as BskyProfile, 18 - com_atproto::repo::{list_records::ListRecords, strong_ref::StrongRef}, 19 - sh_weaver::notebook::{book::Book, entry::Entry, page::Page}, 20 - }; 21 - 22 - // Re-export view types for use elsewhere 23 - pub use weaver_api::sh_weaver::notebook::{ 24 - AuthorListView, BookEntryRef, BookEntryView, EntryView, NotebookView, 25 - }; 26 - 27 - pub async fn view_notebook( 28 - client: Arc<BasicClient>, 29 - uri: &AtUri<'_>, 30 - ) -> Result<(NotebookView<'static>, Vec<StrongRef<'static>>)> { 31 - let notebook = client.get_record::<Book>(uri).await?.into_output()?; 32 - 33 - let title = notebook.value.title.clone(); 34 - let tags = notebook.value.tags.clone(); 35 - 36 - let mut authors = Vec::new(); 37 - 38 - for (index, author) in notebook.value.authors.iter().enumerate() { 39 - // TODO: swap to using weaver profiles here, or pick between them 40 - let author_uri = 41 - BskyProfile::uri(format!("at://{}/app.bsky.actor.profile/self", author.did))?; 42 - let author_profile = client.fetch_record(&author_uri).await?; 43 - 44 - authors.push( 45 - AuthorListView::new() 46 - .uri(author_uri.as_uri().clone()) 47 - .record(to_data(&author_profile)?) 48 - .index(index as i64) 49 - .build(), 50 - ); 51 - } 52 - let entries = notebook 53 - .value 54 - .entry_list 55 - .iter() 56 - .cloned() 57 - .map(IntoStatic::into_static) 58 - .collect(); 59 - 60 - Ok(( 61 - NotebookView::new() 62 - .cid(notebook.cid.unwrap()) 63 - .uri(notebook.uri) 64 - .indexed_at(Datetime::now()) 65 - .maybe_title(title) 66 - .maybe_tags(tags) 67 - .authors(authors) 68 - .record(to_data(&notebook.value)?) 69 - .build(), 70 - entries, 71 - )) 72 - } 73 - 74 - pub async fn fetch_entry_view<'a>( 75 - client: Arc<BasicClient>, 76 - notebook: &NotebookView<'a>, 77 - entry_ref: &StrongRef<'_>, 78 - ) -> Result<EntryView<'a>> { 79 - let entry = client 80 - .fetch_record(&Entry::uri(entry_ref.uri.clone())?) 81 - .await?; 82 - 83 - let title = entry.value.title.clone(); 84 - let tags = entry.value.tags.clone(); 85 - 86 - Ok(EntryView::new() 87 - .cid(entry.cid.unwrap()) 88 - .uri(entry.uri) 89 - .indexed_at(Datetime::now()) 90 - .record(to_data(&entry.value)?) 91 - .maybe_tags(tags) 92 - .title(title) 93 - .authors(notebook.authors.clone()) 94 - .build()) 95 - } 96 - 97 - pub async fn view_entry<'a>( 98 - client: Arc<BasicClient>, 99 - notebook: &NotebookView<'a>, 100 - entries: &[StrongRef<'_>], 101 - index: usize, 102 - ) -> Result<BookEntryView<'a>> { 103 - let entry_ref = entries 104 - .get(index) 105 - .ok_or(miette::miette!("entry out of bounds"))?; 106 - let entry = fetch_entry_view(client.clone(), notebook, entry_ref).await?; 107 - let prev_entry = if index > 0 { 108 - let prev_entry_ref = entries[index - 1].clone(); 109 - fetch_entry_view(client.clone(), notebook, &prev_entry_ref) 110 - .await 111 - .ok() 112 - } else { 113 - None 114 - } 115 - .map(|e| BookEntryRef::new().entry(e).build()); 116 - let next_entry = if index < entries.len() - 1 { 117 - let next_entry_ref = entries[index + 1].clone(); 118 - fetch_entry_view(client.clone(), notebook, &next_entry_ref) 119 - .await 120 - .ok() 121 - } else { 122 - None 123 - } 124 - .map(|e| BookEntryRef::new().entry(e).build()); 125 - Ok(BookEntryView::new() 126 - .entry(entry) 127 - .maybe_next(next_entry) 128 - .maybe_prev(prev_entry) 129 - .index(index as i64) 130 - .build()) 131 - } 132 - 133 - pub async fn fetch_page_view<'a>( 134 - client: Arc<BasicClient>, 135 - notebook: &NotebookView<'a>, 136 - entry_ref: &StrongRef<'_>, 137 - ) -> Result<EntryView<'a>> { 138 - let entry = client 139 - .fetch_record(&Page::uri(entry_ref.uri.clone())?) 140 - .await?; 141 - 142 - let title = entry.value.title.clone(); 143 - let tags = entry.value.tags.clone(); 144 - 145 - Ok(EntryView::new() 146 - .cid(entry.cid.unwrap()) 147 - .uri(entry.uri) 148 - .indexed_at(Datetime::now()) 149 - .record(to_data(&entry.value)?) 150 - .maybe_tags(tags) 151 - .title(title) 152 - .authors(notebook.authors.clone()) 153 - .build()) 154 - } 155 - 156 - pub async fn view_page<'a>( 157 - client: Arc<BasicClient>, 158 - notebook: &NotebookView<'a>, 159 - pages: &[StrongRef<'_>], 160 - index: usize, 161 - ) -> Result<BookEntryView<'a>> { 162 - let entry_ref = pages 163 - .get(index) 164 - .ok_or(miette::miette!("entry out of bounds"))?; 165 - let entry = fetch_page_view(client.clone(), notebook, entry_ref).await?; 166 - let prev_entry = if index > 0 { 167 - let prev_entry_ref = pages[index - 1].clone(); 168 - fetch_page_view(client.clone(), notebook, &prev_entry_ref) 169 - .await 170 - .ok() 171 - } else { 172 - None 173 - } 174 - .map(|e| BookEntryRef::new().entry(e).build()); 175 - let next_entry = if index < pages.len() - 1 { 176 - let next_entry_ref = pages[index + 1].clone(); 177 - fetch_page_view(client.clone(), notebook, &next_entry_ref) 178 - .await 179 - .ok() 180 - } else { 181 - None 182 - } 183 - .map(|e| BookEntryRef::new().entry(e).build()); 184 - Ok(BookEntryView::new() 185 - .entry(entry) 186 - .maybe_next(next_entry) 187 - .maybe_prev(prev_entry) 188 - .index(index as i64) 189 - .build()) 190 - } 191 - 192 - pub async fn entry_by_title<'a>( 193 - client: Arc<BasicClient>, 194 - notebook: &NotebookView<'a>, 195 - entries: &[StrongRef<'_>], 196 - title: &str, 197 - ) -> Result<Option<(BookEntryView<'a>, Entry<'a>)>> { 198 - for (index, entry_ref) in entries.iter().enumerate() { 199 - let resp = client.get_record::<Entry>(&entry_ref.uri).await?; 200 - if let Ok(entry) = resp.parse() 201 - && entry.value.title == title 202 - { 203 - return Ok(Some(( 204 - view_entry(client.clone(), notebook, entries, index).await?, 205 - entry.value.into_static(), 206 - ))); 207 - } 208 - } 209 - Ok(None) 210 - } 211 - 212 - pub async fn notebook_by_title<'a>( 213 - client: Arc<BasicClient>, 214 - ident: &AtIdentifier<'_>, 215 - title: &str, 216 - ) -> Result<Option<(NotebookView<'static>, Vec<StrongRef<'static>>)>> { 217 - let (repo_did, pds_url) = match ident { 218 - AtIdentifier::Did(did) => { 219 - let pds = client.pds_for_did(did).await?; 220 - (did.clone(), pds) 221 - } 222 - AtIdentifier::Handle(handle) => client.pds_for_handle(handle).await?, 223 - }; 224 - // TODO: use the cursor to search through all records with this NSID for the repo 225 - let resp = client 226 - .xrpc(pds_url) 227 - .send( 228 - &ListRecords::new() 229 - .repo(repo_did) 230 - .collection(Nsid::raw(Book::NSID)) 231 - .limit(100) 232 - .build(), 233 - ) 234 - .await?; 235 - if let Ok(list) = resp.parse() { 236 - for record in list.records { 237 - let notebook: Book = from_data(&record.value).into_diagnostic()?; 238 - if let Some(book_title) = notebook.title 239 - && book_title == title 240 - { 241 - let tags = notebook.tags.clone(); 242 - 243 - let mut authors = Vec::new(); 244 - 245 - for (index, author) in notebook.authors.iter().enumerate() { 246 - // TODO: swap to using weaver profiles here, or pick between them 247 - let author_uri = BskyProfile::uri(format!( 248 - "at://{}/app.bsky.actor.profile/self", 249 - author.did 250 - ))?; 251 - let author_profile = client.fetch_record(&author_uri).await?; 252 - 253 - authors.push( 254 - AuthorListView::new() 255 - .uri(author_uri.as_uri().clone()) 256 - .record(to_data(&author_profile)?) 257 - .index(index as i64) 258 - .build(), 259 - ); 260 - } 261 - let entries = notebook 262 - .entry_list 263 - .iter() 264 - .cloned() 265 - .map(IntoStatic::into_static) 266 - .collect(); 267 - 268 - return Ok(Some(( 269 - NotebookView::new() 270 - .cid(record.cid) 271 - .uri(record.uri) 272 - .indexed_at(Datetime::now()) 273 - .title(book_title) 274 - .maybe_tags(tags) 275 - .authors(authors) 276 - .record(record.value.clone()) 277 - .build() 278 - .into_static(), 279 - entries, 280 - ))); 281 - } 282 - } 283 - } 284 - 285 - Ok(None) 286 - }
+1
crates/weaver-renderer/src/atproto/client.rs
··· 443 443 fn test_client_context_creation() { 444 444 let entry = Entry::new() 445 445 .title("Test") 446 + .path(weaver_common::normalize_title_path("Test")) 446 447 .content("# Test") 447 448 .created_at(Datetime::now()) 448 449 .build();
+4 -1
lexicons/notebook/defs.json
··· 63 63 "required": ["record", "index"], 64 64 "properties": { 65 65 "uri": { "type": "string", "format": "at-uri" }, 66 - "record": { "type": "unknown" }, 66 + "record": { 67 + "type": "ref", 68 + "ref": "sh.weaver.actor.defs#profileDataView" 69 + }, 67 70 "index": { "type": "integer" } 68 71 } 69 72 },
+2 -1
lexicons/notebook/entry.json
··· 8 8 "key": "tid", 9 9 "record": { 10 10 "type": "object", 11 - "required": ["content", "title", "createdAt"], 11 + "required": ["content", "title", "path", "createdAt"], 12 12 "properties": { 13 13 "title": { "type": "ref", "ref": "sh.weaver.notebook.defs#title" }, 14 + "path": { "type": "ref", "ref": "sh.weaver.notebook.defs#path" }, 14 15 "tags": { "type": "ref", "ref": "sh.weaver.notebook.defs#tags" }, 15 16 16 17 "content": {