From 999e56912b09907f1436815d2e37706c482522fa Mon Sep 17 00:00:00 2001 From: Orual Date: Sat, 8 Nov 2025 17:58:08 -0500 Subject: [PATCH] reworked nav and profile hydration --- Cargo.lock | 2 + .../lexicons/sh_weaver_notebook_defs.json | 3 +- .../lexicons/sh_weaver_notebook_entry.json | 5 + crates/weaver-api/src/sh_weaver/notebook.rs | 11 +- .../src/sh_weaver/notebook/entry.rs | 67 +- crates/weaver-app/Cargo.toml | 2 + crates/weaver-app/assets/styling/navbar.css | 37 +- .../src/components/avatar/component.rs | 2 + crates/weaver-app/src/components/css.rs | 9 +- crates/weaver-app/src/components/entry.rs | 100 ++- crates/weaver-app/src/components/identity.rs | 78 +- crates/weaver-app/src/fetch.rs | 152 +++- crates/weaver-app/src/main.rs | 10 +- crates/weaver-app/src/service_worker.rs | 2 + crates/weaver-app/src/views/home.rs | 32 +- crates/weaver-app/src/views/navbar.rs | 45 +- crates/weaver-cli/src/main.rs | 4 +- crates/weaver-common/src/agent.rs | 767 ++++++++++++++++++ crates/weaver-common/src/error.rs | 10 +- crates/weaver-common/src/lib.rs | 516 +----------- crates/weaver-common/src/view.rs | 286 ------- crates/weaver-renderer/src/atproto/client.rs | 1 + lexicons/notebook/defs.json | 5 +- lexicons/notebook/entry.json | 3 +- 24 files changed, 1257 insertions(+), 892 deletions(-) create mode 100644 crates/weaver-common/src/agent.rs delete mode 100644 crates/weaver-common/src/view.rs diff --git a/Cargo.lock b/Cargo.lock index c138a4c..69fd376 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8631,6 +8631,8 @@ dependencies = [ "mime-sniffer", "mini-moka", "n0-future", + "reqwest", + "serde", "serde_json", "sqlite-wasm-rs", "time", diff --git a/crates/weaver-api/lexicons/sh_weaver_notebook_defs.json b/crates/weaver-api/lexicons/sh_weaver_notebook_defs.json index af58414..c38adbb 100644 --- a/crates/weaver-api/lexicons/sh_weaver_notebook_defs.json +++ b/crates/weaver-api/lexicons/sh_weaver_notebook_defs.json @@ -13,7 +13,8 @@ "type": "integer" }, "record": { - "type": "unknown" + "type": "ref", + "ref": "sh.weaver.actor.defs#profileDataView" }, "uri": { "type": "string", diff --git a/crates/weaver-api/lexicons/sh_weaver_notebook_entry.json b/crates/weaver-api/lexicons/sh_weaver_notebook_entry.json index 537df25..272dac9 100644 --- a/crates/weaver-api/lexicons/sh_weaver_notebook_entry.json +++ b/crates/weaver-api/lexicons/sh_weaver_notebook_entry.json @@ -11,6 +11,7 @@ "required": [ "content", "title", + "path", "createdAt" ], "properties": { @@ -53,6 +54,10 @@ } } }, + "path": { + "type": "ref", + "ref": "sh.weaver.notebook.defs#path" + }, "tags": { "type": "ref", "ref": "sh.weaver.notebook.defs#tags" diff --git a/crates/weaver-api/src/sh_weaver/notebook.rs b/crates/weaver-api/src/sh_weaver/notebook.rs index da8d1e9..cd51fe3 100644 --- a/crates/weaver-api/src/sh_weaver/notebook.rs +++ b/crates/weaver-api/src/sh_weaver/notebook.rs @@ -27,7 +27,7 @@ pub mod theme; pub struct AuthorListView<'a> { pub index: i64, #[serde(borrow)] - pub record: jacquard_common::types::value::Data<'a>, + pub record: crate::sh_weaver::actor::ProfileDataView<'a>, #[serde(skip_serializing_if = "std::option::Option::is_none")] #[serde(borrow)] pub uri: Option>, @@ -82,7 +82,7 @@ pub struct AuthorListViewBuilder<'a, S: author_list_view_state::State> { _phantom_state: ::core::marker::PhantomData S>, __unsafe_private_named: ( ::core::option::Option, - ::core::option::Option>, + ::core::option::Option>, ::core::option::Option>, ), _phantom: ::core::marker::PhantomData<&'a ()>, @@ -133,7 +133,7 @@ where /// Set the `record` field (required) pub fn record( mut self, - value: impl Into>, + value: impl Into>, ) -> AuthorListViewBuilder<'a, author_list_view_state::SetRecord> { self.__unsafe_private_named.1 = ::core::option::Option::Some(value.into()); AuthorListViewBuilder { @@ -232,8 +232,11 @@ fn lexicon_doc_sh_weaver_notebook_defs() -> ::jacquard_lexicon::lexicon::Lexicon ); map.insert( ::jacquard_common::smol_str::SmolStr::new_static("record"), - ::jacquard_lexicon::lexicon::LexObjectProperty::Unknown(::jacquard_lexicon::lexicon::LexUnknown { + ::jacquard_lexicon::lexicon::LexObjectProperty::Ref(::jacquard_lexicon::lexicon::LexRef { description: None, + r#ref: ::jacquard_common::CowStr::new_static( + "sh.weaver.actor.defs#profileDataView", + ), }), ); map.insert( diff --git a/crates/weaver-api/src/sh_weaver/notebook/entry.rs b/crates/weaver-api/src/sh_weaver/notebook/entry.rs index fba643f..533465c 100644 --- a/crates/weaver-api/src/sh_weaver/notebook/entry.rs +++ b/crates/weaver-api/src/sh_weaver/notebook/entry.rs @@ -27,6 +27,8 @@ pub struct Entry<'a> { #[serde(skip_serializing_if = "std::option::Option::is_none")] #[serde(borrow)] pub embeds: Option>, + #[serde(borrow)] + pub path: crate::sh_weaver::notebook::Path<'a>, #[serde(skip_serializing_if = "std::option::Option::is_none")] #[serde(borrow)] pub tags: Option>, @@ -46,6 +48,7 @@ pub mod entry_state { pub trait State: sealed::Sealed { type Content; type Title; + type Path; type CreatedAt; } /// Empty state - all required fields are unset @@ -54,6 +57,7 @@ pub mod entry_state { impl State for Empty { type Content = Unset; type Title = Unset; + type Path = Unset; type CreatedAt = Unset; } ///State transition - sets the `content` field to Set @@ -62,6 +66,7 @@ pub mod entry_state { impl State for SetContent { type Content = Set; type Title = S::Title; + type Path = S::Path; type CreatedAt = S::CreatedAt; } ///State transition - sets the `title` field to Set @@ -70,6 +75,16 @@ pub mod entry_state { impl State for SetTitle { type Content = S::Content; type Title = Set; + type Path = S::Path; + type CreatedAt = S::CreatedAt; + } + ///State transition - sets the `path` field to Set + pub struct SetPath(PhantomData S>); + impl sealed::Sealed for SetPath {} + impl State for SetPath { + type Content = S::Content; + type Title = S::Title; + type Path = Set; type CreatedAt = S::CreatedAt; } ///State transition - sets the `created_at` field to Set @@ -78,6 +93,7 @@ pub mod entry_state { impl State for SetCreatedAt { type Content = S::Content; type Title = S::Title; + type Path = S::Path; type CreatedAt = Set; } /// Marker types for field names @@ -87,6 +103,8 @@ pub mod entry_state { pub struct content(()); ///Marker type for the `title` field pub struct title(()); + ///Marker type for the `path` field + pub struct path(()); ///Marker type for the `created_at` field pub struct created_at(()); } @@ -99,6 +117,7 @@ pub struct EntryBuilder<'a, S: entry_state::State> { ::core::option::Option>, ::core::option::Option, ::core::option::Option>, + ::core::option::Option>, ::core::option::Option>, ::core::option::Option>, ), @@ -117,7 +136,7 @@ impl<'a> EntryBuilder<'a, entry_state::Empty> { pub fn new() -> Self { EntryBuilder { _phantom_state: ::core::marker::PhantomData, - __unsafe_private_named: (None, None, None, None, None), + __unsafe_private_named: (None, None, None, None, None, None), _phantom: ::core::marker::PhantomData, } } @@ -174,13 +193,32 @@ impl<'a, S: entry_state::State> EntryBuilder<'a, S> { } } +impl<'a, S> EntryBuilder<'a, S> +where + S: entry_state::State, + S::Path: entry_state::IsUnset, +{ + /// Set the `path` field (required) + pub fn path( + mut self, + value: impl Into>, + ) -> EntryBuilder<'a, entry_state::SetPath> { + self.__unsafe_private_named.3 = ::core::option::Option::Some(value.into()); + EntryBuilder { + _phantom_state: ::core::marker::PhantomData, + __unsafe_private_named: self.__unsafe_private_named, + _phantom: ::core::marker::PhantomData, + } + } +} + impl<'a, S: entry_state::State> EntryBuilder<'a, S> { /// Set the `tags` field (optional) pub fn tags( mut self, value: impl Into>>, ) -> Self { - self.__unsafe_private_named.3 = value.into(); + self.__unsafe_private_named.4 = value.into(); self } /// Set the `tags` field to an Option value (optional) @@ -188,7 +226,7 @@ impl<'a, S: entry_state::State> EntryBuilder<'a, S> { mut self, value: Option>, ) -> Self { - self.__unsafe_private_named.3 = value; + self.__unsafe_private_named.4 = value; self } } @@ -203,7 +241,7 @@ where mut self, value: impl Into>, ) -> EntryBuilder<'a, entry_state::SetTitle> { - self.__unsafe_private_named.4 = ::core::option::Option::Some(value.into()); + self.__unsafe_private_named.5 = ::core::option::Option::Some(value.into()); EntryBuilder { _phantom_state: ::core::marker::PhantomData, __unsafe_private_named: self.__unsafe_private_named, @@ -217,6 +255,7 @@ where S: entry_state::State, S::Content: entry_state::IsSet, S::Title: entry_state::IsSet, + S::Path: entry_state::IsSet, S::CreatedAt: entry_state::IsSet, { /// Build the final struct @@ -225,8 +264,9 @@ where content: self.__unsafe_private_named.0.unwrap(), created_at: self.__unsafe_private_named.1.unwrap(), embeds: self.__unsafe_private_named.2, - tags: self.__unsafe_private_named.3, - title: self.__unsafe_private_named.4.unwrap(), + path: self.__unsafe_private_named.3.unwrap(), + tags: self.__unsafe_private_named.4, + title: self.__unsafe_private_named.5.unwrap(), extra_data: Default::default(), } } @@ -242,8 +282,9 @@ where content: self.__unsafe_private_named.0.unwrap(), created_at: self.__unsafe_private_named.1.unwrap(), embeds: self.__unsafe_private_named.2, - tags: self.__unsafe_private_named.3, - title: self.__unsafe_private_named.4.unwrap(), + path: self.__unsafe_private_named.3.unwrap(), + tags: self.__unsafe_private_named.4, + title: self.__unsafe_private_named.5.unwrap(), extra_data: Some(extra_data), } } @@ -318,6 +359,7 @@ fn lexicon_doc_sh_weaver_notebook_entry() -> ::jacquard_lexicon::lexicon::Lexico vec![ ::jacquard_common::smol_str::SmolStr::new_static("content"), ::jacquard_common::smol_str::SmolStr::new_static("title"), + ::jacquard_common::smol_str::SmolStr::new_static("path"), ::jacquard_common::smol_str::SmolStr::new_static("createdAt") ], ), @@ -438,6 +480,15 @@ fn lexicon_doc_sh_weaver_notebook_entry() -> ::jacquard_lexicon::lexicon::Lexico }, }), ); + map.insert( + ::jacquard_common::smol_str::SmolStr::new_static("path"), + ::jacquard_lexicon::lexicon::LexObjectProperty::Ref(::jacquard_lexicon::lexicon::LexRef { + description: None, + r#ref: ::jacquard_common::CowStr::new_static( + "sh.weaver.notebook.defs#path", + ), + }), + ); map.insert( ::jacquard_common::smol_str::SmolStr::new_static("tags"), ::jacquard_lexicon::lexicon::LexObjectProperty::Ref(::jacquard_lexicon::lexicon::LexRef { diff --git a/crates/weaver-app/Cargo.toml b/crates/weaver-app/Cargo.toml index 9a5e860..d16199d 100644 --- a/crates/weaver-app/Cargo.toml +++ b/crates/weaver-app/Cargo.toml @@ -35,7 +35,9 @@ dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version axum = {version = "0.8.6", optional = true} mime-sniffer = {version = "^0.1"} chrono = { version = "0.4" } +serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } diesel = { version = "2.3", features = ["sqlite", "returning_clauses_for_sqlite_3_35", "chrono", "serde_json"] } diesel_migrations = { version = "2.3", features = ["sqlite"] } diff --git a/crates/weaver-app/assets/styling/navbar.css b/crates/weaver-app/assets/styling/navbar.css index c910115..6a338c0 100644 --- a/crates/weaver-app/assets/styling/navbar.css +++ b/crates/weaver-app/assets/styling/navbar.css @@ -1,16 +1,31 @@ #navbar { - display: flex; - flex-direction: row; + display: flex; + flex-direction: row; } -#navbar a { - color: var(--color-text); - margin-right: 20px; - text-decoration: none; - transition: color 0.2s ease; +.breadcrumbs { + display: flex; + align-items: center; + gap: 0.5rem; } -#navbar a:hover { - cursor: pointer; - color: var(--color-primary); -} \ No newline at end of file +.breadcrumb { + color: var(--color-text); + text-decoration: none; + transition: color 0.2s ease; +} + +.breadcrumb:hover { + cursor: pointer; + color: var(--color-primary); +} + +.breadcrumb-current { + color: var(--color-text-muted, #666); + font-weight: 500; +} + +.breadcrumb-separator { + color: var(--color-text-muted, #999); + user-select: none; +} diff --git a/crates/weaver-app/src/components/avatar/component.rs b/crates/weaver-app/src/components/avatar/component.rs index ddb43d7..30374bb 100644 --- a/crates/weaver-app/src/components/avatar/component.rs +++ b/crates/weaver-app/src/components/avatar/component.rs @@ -5,7 +5,9 @@ use dioxus_primitives::avatar::{self, AvatarFallbackProps, AvatarImageProps, Ava pub enum AvatarImageSize { #[default] Small, + #[allow(dead_code)] Medium, + #[allow(dead_code)] Large, } diff --git a/crates/weaver-app/src/components/css.rs b/crates/weaver-app/src/components/css.rs index 16874f3..1d23ff2 100644 --- a/crates/weaver-app/src/components/css.rs +++ b/crates/weaver-app/src/components/css.rs @@ -4,12 +4,7 @@ use crate::fetch; use dioxus::{prelude::*, CapturedError}; #[cfg(feature = "fullstack-server")] -use dioxus::fullstack::{ - get_server_url, - headers::ContentType, - http::header::CONTENT_TYPE, - response::{self, Response}, -}; +use dioxus::fullstack::response::Response; use jacquard::smol_str::SmolStr; #[allow(unused_imports)] use std::sync::Arc; @@ -22,6 +17,7 @@ use axum::{extract::Extension, response::IntoResponse}; #[cfg(feature = "fullstack-server")] #[component] pub fn NotebookCss(ident: SmolStr, notebook: SmolStr) -> Element { + use dioxus::fullstack::get_server_url; rsx! { document::Stylesheet { href: "{get_server_url()}/{ident}/{notebook}/css" @@ -93,6 +89,7 @@ pub fn NotebookCss(ident: SmolStr, notebook: SmolStr) -> Element { #[cfg(feature = "fullstack-server")] #[get("/{ident}/{notebook}/css", fetcher: Extension>)] pub async fn css(ident: SmolStr, notebook: SmolStr) -> Result { + use dioxus::fullstack::http::header::CONTENT_TYPE; use jacquard::client::AgentSessionExt; use jacquard::types::ident::AtIdentifier; use jacquard::{from_data, CowStr}; diff --git a/crates/weaver-app/src/components/entry.rs b/crates/weaver-app/src/components/entry.rs index f4f2efa..1f878b2 100644 --- a/crates/weaver-app/src/components/entry.rs +++ b/crates/weaver-app/src/components/entry.rs @@ -3,9 +3,8 @@ #[cfg(feature = "server")] use crate::blobcache::BlobCache; use crate::{ - components::avatar::{Avatar, AvatarFallback, AvatarImage}, + components::avatar::{Avatar, AvatarImage}, data::use_handle, - fetch, }; use crate::Route; @@ -13,10 +12,9 @@ use dioxus::prelude::*; const ENTRY_CSS: Asset = asset!("/assets/styling/entry.css"); -use jacquard::prelude::*; #[allow(unused_imports)] use jacquard::smol_str::ToSmolStr; -use jacquard::{from_data, types::string::Datetime}; +use jacquard::types::string::Datetime; #[allow(unused_imports)] use jacquard::{ smol_str::SmolStr, @@ -166,7 +164,6 @@ fn EntryPage( pub fn EntryCard(entry: BookEntryView<'static>, book_title: SmolStr) -> Element { use crate::Route; use jacquard::{from_data, IntoStatic}; - use weaver_api::app_bsky::actor::profile::Profile; use weaver_api::sh_weaver::notebook::entry::Entry; let entry_view = &entry.entry; @@ -219,30 +216,41 @@ pub fn EntryCard(entry: BookEntryView<'static>, book_title: SmolStr) -> Element if let Some(author) = first_author { div { class: "entry-card-author", { - match author.record.get_at_path(".value").and_then(|v| from_data::(v).ok()) { - Some(profile) => { - let avatar = profile.avatar - .map(|avatar| { - let cid = avatar.blob().cid(); - format!("https://cdn.bsky.app/img/avatar/plain/{}/{cid}@jpeg", entry_view.uri.authority().as_ref()) - }); - let display_name = profile.display_name - .as_ref() - .map(|n| n.as_ref()) - .unwrap_or("Unknown"); + use weaver_api::sh_weaver::actor::ProfileDataViewInner; + + match &author.record.inner { + ProfileDataViewInner::ProfileView(profile) => { + let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown"); + rsx! { + if let Some(ref avatar_url) = profile.avatar { + Avatar { + AvatarImage { src: avatar_url.as_ref() } + } + } + span { class: "author-name", "{display_name}" } + span { class: "meta-label", "@{ident}" } + } + } + ProfileDataViewInner::ProfileViewDetailed(profile) => { + let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown"); rsx! { - if let Some(avatar_url) = avatar { + if let Some(ref avatar_url) = profile.avatar { Avatar { - AvatarImage { src: avatar_url } + AvatarImage { src: avatar_url.as_ref() } } } span { class: "author-name", "{display_name}" } span { class: "meta-label", "@{ident}" } } } - None => { + ProfileDataViewInner::TangledProfileView(profile) => { + rsx! { + span { class: "author-name", "@{profile.handle.as_ref()}" } + } + } + _ => { rsx! { - span { class: "author-name", "Author {author.index}" } + span { class: "author-name", "Unknown" } } } } @@ -279,8 +287,6 @@ fn EntryMetadata( ident: AtIdentifier<'static>, created_at: Datetime, ) -> Element { - use weaver_api::app_bsky::actor::profile::Profile; - let title = entry_view .title .as_ref() @@ -300,27 +306,38 @@ fn EntryMetadata( for (i, author) in entry_view.authors.iter().enumerate() { if i > 0 { span { ", " } } { - // Parse author profile from the nested value field - match author.record.get_at_path(".value").and_then(|v| from_data::(v).ok()) { - Some(profile) => { - let avatar = profile.avatar - .map(|avatar| { - let cid = avatar.blob().cid(); - let did = entry_view.uri.authority(); - format!("https://cdn.bsky.app/img/avatar/plain/{did}/{cid}@jpeg") - }); - let display_name = profile.display_name - .as_ref() - .map(|n| n.as_ref()) - .unwrap_or("Unknown"); + use weaver_api::sh_weaver::actor::ProfileDataViewInner; + + match &author.record.inner { + ProfileDataViewInner::ProfileView(profile) => { + let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown"); + rsx! { + Link { + to: Route::RepositoryIndex { ident: ident.clone() }, + div { class: "entry-authors", + if let Some(ref avatar_url) = profile.avatar { + Avatar { + AvatarImage { + src: avatar_url.as_ref() + } + } + } + span { class: "author-name", "{display_name}" } + span { class: "meta-label", "@{ident}" } + } + } + } + } + ProfileDataViewInner::ProfileViewDetailed(profile) => { + let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown"); rsx! { Link { to: Route::RepositoryIndex { ident: ident.clone() }, div { class: "entry-authors", - if let Some(avatar) = avatar { + if let Some(ref avatar_url) = profile.avatar { Avatar { AvatarImage { - src: avatar + src: avatar_url.as_ref() } } } @@ -330,9 +347,14 @@ fn EntryMetadata( } } } - None => { + ProfileDataViewInner::TangledProfileView(profile) => { + rsx! { + span { class: "author-name", "@{profile.handle.as_ref()}" } + } + } + _ => { rsx! { - span { class: "author-name", "Author {author.index}" } + span { class: "author-name", "Unknown" } } } } diff --git a/crates/weaver-app/src/components/identity.rs b/crates/weaver-app/src/components/identity.rs index 8ffcadc..3ba49ea 100644 --- a/crates/weaver-app/src/components/identity.rs +++ b/crates/weaver-app/src/components/identity.rs @@ -18,20 +18,36 @@ pub fn Repository(ident: AtIdentifier<'static>) -> Element { #[component] pub fn RepositoryIndex(ident: AtIdentifier<'static>) -> Element { let fetcher = use_context::(); - let notebooks = use_signal(|| fetcher.list_recent_notebooks()); + + // Fetch notebooks for this specific DID + let notebooks = use_resource(use_reactive!(|ident| { + let fetcher = fetcher.clone(); + async move { fetcher.fetch_notebooks_for_did(&ident).await } + })); + rsx! { document::Link { rel: "stylesheet", href: NOTEBOOK_CARD_CSS } div { class: "notebooks-list", - for notebook in notebooks.iter() { - { - let view = ¬ebook.0; - rsx! { - div { - key: "{view.cid}", - NotebookCard { notebook: view.clone() } + match notebooks() { + Some(Ok(notebook_list)) => rsx! { + for notebook in notebook_list.iter() { + { + let view = ¬ebook.0; + rsx! { + div { + key: "{view.cid}", + NotebookCard { notebook: view.clone() } + } + } } } + }, + Some(Err(_)) => rsx! { + div { "Error loading notebooks" } + }, + None => rsx! { + div { "Loading notebooks..." } } } } @@ -41,9 +57,7 @@ pub fn RepositoryIndex(ident: AtIdentifier<'static>) -> Element { #[component] pub fn NotebookCard(notebook: NotebookView<'static>) -> Element { use crate::components::avatar::{Avatar, AvatarImage}; - use jacquard::{from_data, prelude::IdentityResolver, IntoStatic}; - use weaver_api::app_bsky::actor::profile::Profile; - use weaver_api::sh_weaver::notebook::book::Book; + use jacquard::IntoStatic; let title = notebook .title @@ -58,8 +72,6 @@ pub fn NotebookCard(notebook: NotebookView<'static>) -> Element { let first_author = notebook.authors.first(); let ident = notebook.uri.authority().clone().into_static(); - let ident_for_avatar = ident.clone(); - rsx! { div { class: "notebook-card", Link { @@ -78,29 +90,39 @@ pub fn NotebookCard(notebook: NotebookView<'static>) -> Element { if let Some(author) = first_author { div { class: "notebook-card-author", { - match from_data::(author.record.get_at_path(".value").unwrap()) { - Ok(profile) => { - let avatar = profile.avatar - .map(|avatar| { - let cid = avatar.blob().cid(); - format!("https://cdn.bsky.app/img/avatar/plain/{}/{cid}@jpeg", ident_for_avatar.as_ref()) - }); - let display_name = profile.display_name - .as_ref() - .map(|n| n.as_ref()) - .unwrap_or("Unknown"); + use weaver_api::sh_weaver::actor::ProfileDataViewInner; + + match &author.record.inner { + ProfileDataViewInner::ProfileView(profile) => { + let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown"); + rsx! { + if let Some(ref avatar_url) = profile.avatar { + Avatar { + AvatarImage { src: avatar_url.as_ref() } + } + } + span { class: "author-name", "{display_name}" } + } + } + ProfileDataViewInner::ProfileViewDetailed(profile) => { + let display_name = profile.display_name.as_ref().map(|n| n.as_ref()).unwrap_or("Unknown"); rsx! { - if let Some(avatar_url) = avatar { + if let Some(ref avatar_url) = profile.avatar { Avatar { - AvatarImage { src: avatar_url } + AvatarImage { src: avatar_url.as_ref() } } } span { class: "author-name", "{display_name}" } } } - Err(_) => { + ProfileDataViewInner::TangledProfileView(profile) => { + rsx! { + span { class: "author-name", "@{profile.handle.as_ref()}" } + } + } + _ => { rsx! { - span { class: "author-name", "Author {author.index}" } + span { class: "author-name", "Unknown" } } } } diff --git a/crates/weaver-app/src/fetch.rs b/crates/weaver-app/src/fetch.rs index a26a4d5..3d3e07f 100644 --- a/crates/weaver-app/src/fetch.rs +++ b/crates/weaver-app/src/fetch.rs @@ -1,12 +1,23 @@ use crate::cache_impl; use dioxus::Result; +use jacquard::prelude::*; use jacquard::{client::BasicClient, smol_str::SmolStr, types::ident::AtIdentifier}; +use serde::{Deserialize, Serialize}; use std::{sync::Arc, time::Duration}; use weaver_api::{ com_atproto::repo::strong_ref::StrongRef, sh_weaver::notebook::{entry::Entry, BookEntryView, NotebookView}, }; -use weaver_common::view::{entry_by_title, notebook_by_title}; +use weaver_common::WeaverExt; + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct UfosRecord { + collection: String, + did: String, + record: serde_json::Value, + rkey: String, + time_us: u64, +} #[derive(Clone)] pub struct CachedFetcher { @@ -39,7 +50,8 @@ impl CachedFetcher { Ok(Some(entry)) } else { if let Some((notebook, entries)) = - notebook_by_title(self.client.clone(), &ident, &title) + self.client + .notebook_by_title(&ident, &title) .await .map_err(|e| dioxus::CapturedError::from_display(e))? { @@ -65,14 +77,11 @@ impl CachedFetcher { { Ok(Some(entry)) } else { - if let Some(entry) = entry_by_title( - self.client.clone(), - notebook, - entries.as_ref(), - &entry_title, - ) - .await - .map_err(|e| dioxus::CapturedError::from_display(e))? + if let Some(entry) = self + .client + .entry_by_title(notebook, entries.as_ref(), &entry_title) + .await + .map_err(|e| dioxus::CapturedError::from_display(e))? { let stored = Arc::new(entry); cache_impl::insert(&self.entry_cache, (ident, entry_title), stored.clone()); @@ -86,14 +95,123 @@ impl CachedFetcher { } } - pub fn list_recent_entries(&self) -> Vec, Entry<'static>)>> { - cache_impl::iter(&self.entry_cache) + pub async fn fetch_notebooks_from_ufos( + &self, + ) -> Result, Vec>)>>> { + use jacquard::{types::aturi::AtUri, IntoStatic}; + + let url = "https://ufos-api.microcosm.blue/records?collection=sh.weaver.notebook.book"; + let response = reqwest::get(url) + .await + .map_err(|e| dioxus::CapturedError::from_display(e))?; + + let records: Vec = response + .json() + .await + .map_err(|e| dioxus::CapturedError::from_display(e))?; + + let mut notebooks = Vec::new(); + + for ufos_record in records { + // Construct URI + let uri_str = format!( + "at://{}/{}/{}", + ufos_record.did, ufos_record.collection, ufos_record.rkey + ); + let uri = AtUri::new_owned(uri_str) + .map_err(|e| dioxus::CapturedError::from_display(format!("Invalid URI: {}", e)))?; + + // Fetch the full notebook view (which hydrates authors) + match self.client.view_notebook(&uri).await { + Ok((notebook, entries)) => { + let ident = uri.authority().clone().into_static(); + let title = notebook + .title + .as_ref() + .map(|t| SmolStr::new(t.as_ref())) + .unwrap_or_else(|| SmolStr::new("Untitled")); + + let result = Arc::new((notebook, entries)); + // Cache it + cache_impl::insert(&self.book_cache, (ident, title), result.clone()); + notebooks.push(result); + } + Err(_) => continue, // Skip notebooks that fail to load + } + } + + Ok(notebooks) } - pub fn list_recent_notebooks( + pub async fn fetch_notebooks_for_did( &self, - ) -> Vec, Vec>)>> { - cache_impl::iter(&self.book_cache) + ident: &AtIdentifier<'_>, + ) -> Result, Vec>)>>> { + use jacquard::{ + types::{collection::Collection, nsid::Nsid}, + xrpc::XrpcExt, + IntoStatic, + }; + use weaver_api::{ + com_atproto::repo::list_records::ListRecords, sh_weaver::notebook::book::Book, + }; + + // Resolve DID and PDS + let (repo_did, pds_url) = match ident { + AtIdentifier::Did(did) => { + let pds = self + .client + .pds_for_did(did) + .await + .map_err(|e| dioxus::CapturedError::from_display(e))?; + (did.clone(), pds) + } + AtIdentifier::Handle(handle) => self + .client + .pds_for_handle(handle) + .await + .map_err(|e| dioxus::CapturedError::from_display(e))?, + }; + + // Fetch all notebook records for this repo + let resp = self + .client + .xrpc(pds_url) + .send( + &ListRecords::new() + .repo(repo_did) + .collection(Nsid::raw(Book::NSID)) + .limit(100) + .build(), + ) + .await + .map_err(|e| dioxus::CapturedError::from_display(e))?; + + let mut notebooks = Vec::new(); + + if let Ok(list) = resp.parse() { + for record in list.records { + // View the notebook (which hydrates authors) + match self.client.view_notebook(&record.uri).await { + Ok((notebook, entries)) => { + let ident = record.uri.authority().clone().into_static(); + let title = notebook + .title + .as_ref() + .map(|t| SmolStr::new(t.as_ref())) + .unwrap_or_else(|| SmolStr::new("Untitled")); + + let result = Arc::new((notebook, entries)); + // Cache it + cache_impl::insert(&self.book_cache, (ident, title), result.clone()); + notebooks.push(result); + } + Err(_) => continue, // Skip notebooks that fail to load + } + } + } + + Ok(notebooks) } pub async fn list_notebook_entries( @@ -101,14 +219,12 @@ impl CachedFetcher { ident: AtIdentifier<'static>, book_title: SmolStr, ) -> Result>>> { - use weaver_common::view::view_entry; - if let Some(result) = self.get_notebook(ident.clone(), book_title).await? { let (notebook, entries) = result.as_ref(); let mut book_entries = Vec::new(); for index in 0..entries.len() { - match view_entry(self.client.clone(), notebook, entries, index).await { + match self.client.view_entry(notebook, entries, index).await { Ok(book_entry) => book_entries.push(book_entry), Err(_) => continue, // Skip entries that fail to load } diff --git a/crates/weaver-app/src/main.rs b/crates/weaver-app/src/main.rs index 9478cf0..6330089 100644 --- a/crates/weaver-app/src/main.rs +++ b/crates/weaver-app/src/main.rs @@ -4,8 +4,10 @@ use components::{Entry, Repository, RepositoryIndex}; #[allow(unused)] use dioxus::{prelude::*, CapturedError}; +#[cfg(all(feature = "fullstack-server", feature = "server"))] +use dioxus::fullstack::response::Extension; #[cfg(feature = "fullstack-server")] -use dioxus::fullstack::{response::Extension, FullstackContext}; +use dioxus::fullstack::FullstackContext; #[allow(unused)] use jacquard::{ client::BasicClient, @@ -101,10 +103,12 @@ fn main() { .merge(dioxus::server::router(App)) }; + let client = Arc::new(BasicClient::unauthenticated()); + #[cfg(feature = "fullstack-server")] let router = { - let fetcher = Arc::new(CachedFetcher::new(Arc::new(BasicClient::unauthenticated()))); - let blob_cache = Arc::new(BlobCache::new(Arc::new(BasicClient::unauthenticated()))); + let fetcher = Arc::new(CachedFetcher::new(client.clone())); + let blob_cache = Arc::new(BlobCache::new(client.clone())); dioxus::server::router(App).layer(middleware::from_fn({ let fetcher = fetcher.clone(); let blob_cache = blob_cache.clone(); diff --git a/crates/weaver-app/src/service_worker.rs b/crates/weaver-app/src/service_worker.rs index e672aab..4d56fd4 100644 --- a/crates/weaver-app/src/service_worker.rs +++ b/crates/weaver-app/src/service_worker.rs @@ -101,11 +101,13 @@ fn send_blob_mappings( Ok(()) } +#[allow(unused)] #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] pub async fn register_service_worker() -> Result<(), String> { Ok(()) } +#[allow(unused)] #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] pub fn send_blob_mappings( _notebook: &str, diff --git a/crates/weaver-app/src/views/home.rs b/crates/weaver-app/src/views/home.rs index 9c96b29..36d23d1 100644 --- a/crates/weaver-app/src/views/home.rs +++ b/crates/weaver-app/src/views/home.rs @@ -7,20 +7,36 @@ const NOTEBOOK_CARD_CSS: Asset = asset!("/assets/styling/notebook-card.css"); #[component] pub fn Home() -> Element { let fetcher = use_context::(); - let notebooks = use_signal(|| fetcher.list_recent_notebooks()); + + // Fetch notebooks from UFOS + let notebooks = use_resource(move || { + let fetcher = fetcher.clone(); + async move { fetcher.fetch_notebooks_from_ufos().await } + }); + rsx! { document::Link { rel: "stylesheet", href: NOTEBOOK_CARD_CSS } div { class: "notebooks-list", - for notebook in notebooks.iter() { - { - let view = ¬ebook.0; - rsx! { - div { - key: "{view.cid}", - NotebookCard { notebook: view.clone() } + match notebooks() { + Some(Ok(notebook_list)) => rsx! { + for notebook in notebook_list.iter() { + { + let view = ¬ebook.0; + rsx! { + div { + key: "{view.cid}", + NotebookCard { notebook: view.clone() } + } + } } } + }, + Some(Err(_)) => rsx! { + div { "Error loading notebooks" } + }, + None => rsx! { + div { "Loading notebooks..." } } } } diff --git a/crates/weaver-app/src/views/navbar.rs b/crates/weaver-app/src/views/navbar.rs index 3b5473d..07fcce3 100644 --- a/crates/weaver-app/src/views/navbar.rs +++ b/crates/weaver-app/src/views/navbar.rs @@ -11,17 +11,54 @@ const NAVBAR_CSS: Asset = asset!("/assets/styling/navbar.css"); /// routes will be rendered under the outlet inside this component #[component] pub fn Navbar() -> Element { + let route = use_route::(); + rsx! { document::Link { rel: "stylesheet", href: THEME_DEFAULTS_CSS } document::Link { rel: "stylesheet", href: NAVBAR_CSS } div { id: "navbar", - Link { - to: Route::Home {}, - "Home" - } + nav { class: "breadcrumbs", + Link { + to: Route::Home {}, + class: "breadcrumb", + "Home" + } + // Show repository breadcrumb if we're on a repository page + match route { + Route::RepositoryIndex { ident } => rsx! { + span { class: "breadcrumb-separator", " > " } + span { class: "breadcrumb breadcrumb-current", "@{ident}" } + }, + Route::NotebookIndex { ident, book_title } => rsx! { + span { class: "breadcrumb-separator", " > " } + Link { + to: Route::RepositoryIndex { ident: ident.clone() }, + class: "breadcrumb", + "@{ident}" + } + span { class: "breadcrumb-separator", " > " } + span { class: "breadcrumb breadcrumb-current", "{book_title}" } + }, + Route::Entry { ident, book_title, .. } => rsx! { + span { class: "breadcrumb-separator", " > " } + Link { + to: Route::RepositoryIndex { ident: ident.clone() }, + class: "breadcrumb", + "@{ident}" + } + span { class: "breadcrumb-separator", " > " } + Link { + to: Route::NotebookIndex { ident: ident.clone(), book_title: book_title.clone() }, + class: "breadcrumb", + "{book_title}" + } + }, + _ => rsx! {} + } + } } // The `Outlet` component is used to render the next component inside the layout. In this case, it will render either diff --git a/crates/weaver-cli/src/main.rs b/crates/weaver-cli/src/main.rs index ccac8b8..b0b0878 100644 --- a/crates/weaver-cli/src/main.rs +++ b/crates/weaver-cli/src/main.rs @@ -9,6 +9,7 @@ use miette::{IntoDiagnostic, Result}; use std::io::BufRead; use std::path::PathBuf; use std::sync::Arc; +use weaver_common::normalize_title_path; use weaver_renderer::atproto::AtProtoPreprocessContext; use weaver_renderer::static_site::StaticSiteWriter; use weaver_renderer::utils::VaultBrokenLinkCallback; @@ -206,7 +207,7 @@ async fn publish_notebook(source: PathBuf, title: String, store_path: PathBuf) - tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("debug")) + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("debug")), ) .init(); @@ -379,6 +380,7 @@ async fn publish_notebook(source: PathBuf, title: String, store_path: PathBuf) - let entry = Entry::new() .content(output.as_str()) .title(entry_title.as_ref()) + .path(normalize_title_path(entry_title.as_ref())) .created_at(Datetime::now()) .maybe_embeds(embeds) .build(); diff --git a/crates/weaver-common/src/agent.rs b/crates/weaver-common/src/agent.rs new file mode 100644 index 0000000..e5275f4 --- /dev/null +++ b/crates/weaver-common/src/agent.rs @@ -0,0 +1,767 @@ +// Re-export view types for use elsewhere +pub use weaver_api::sh_weaver::notebook::{ + AuthorListView, BookEntryRef, BookEntryView, EntryView, NotebookView, +}; + +// Re-export jacquard for convenience +use crate::error::WeaverError; +pub use jacquard; +use jacquard::bytes::Bytes; +use jacquard::client::{Agent, AgentError, AgentErrorKind, AgentSession, AgentSessionExt}; +use jacquard::error::ClientError; +use jacquard::prelude::*; +use jacquard::types::blob::{BlobRef, MimeType}; +use jacquard::types::string::{AtUri, Did, RecordKey}; +use jacquard::types::tid::Tid; +use jacquard::xrpc::Response; +use jacquard::{IntoStatic, xrpc}; +use mime_sniffer::MimeTypeSniffer; +use std::path::Path; +use weaver_api::com_atproto::repo::get_record::GetRecordResponse; +use weaver_api::com_atproto::repo::strong_ref::StrongRef; +use weaver_api::sh_weaver::notebook::entry; +use weaver_api::sh_weaver::publish::blob::Blob as PublishedBlob; + +use crate::{PublishResult, W_TICKER, WeaverExt, normalize_title_path}; + +impl WeaverExt for Agent { + async fn publish_notebook(&self, _path: &Path) -> Result, WeaverError> { + // TODO: Implementation + todo!("publish_notebook not yet implemented") + } + + async fn publish_blob<'a>( + &self, + blob: Bytes, + url_path: &'a str, + prev: Option, + ) -> Result<(StrongRef<'a>, PublishedBlob<'a>), WeaverError> { + let mime_type = + MimeType::new_owned(blob.sniff_mime_type().unwrap_or("application/octet-stream")); + + let blob = self.upload_blob(blob, mime_type).await?; + let publish_record = PublishedBlob::new() + .path(url_path) + .upload(BlobRef::Blob(blob)) + .build(); + let tid = W_TICKER.lock().await.next(prev); + let record = self + .create_record(publish_record.clone(), Some(RecordKey::any(tid.as_str())?)) + .await?; + let strong_ref = StrongRef::new().uri(record.uri).cid(record.cid).build(); + + Ok((strong_ref, publish_record)) + } + + async fn upsert_notebook( + &self, + title: &str, + author_did: &Did<'_>, + ) -> Result<(AtUri<'static>, Vec>), WeaverError> { + use jacquard::types::collection::Collection; + use jacquard::types::nsid::Nsid; + use jacquard::xrpc::XrpcExt; + use weaver_api::com_atproto::repo::list_records::ListRecords; + use weaver_api::sh_weaver::notebook::book::Book; + + // Find the PDS for this DID + let pds_url = self.pds_for_did(author_did).await.map_err(|e| { + AgentError::from(ClientError::from(e).with_context("Failed to resolve PDS for DID")) + })?; + + // Search for existing notebook with this title + let resp = self + .xrpc(pds_url) + .send( + &ListRecords::new() + .repo(author_did.clone()) + .collection(Nsid::raw(Book::NSID)) + .limit(100) + .build(), + ) + .await + .map_err(|e| AgentError::from(ClientError::from(e)))?; + + if let Ok(list) = resp.parse() { + for record in list.records { + let notebook: Book = jacquard::from_data(&record.value).map_err(|_| { + AgentError::from(ClientError::invalid_request( + "Failed to parse notebook record", + )) + })?; + if let Some(book_title) = notebook.title + && book_title == title + { + let entries = notebook + .entry_list + .iter() + .cloned() + .map(IntoStatic::into_static) + .collect(); + return Ok((record.uri.into_static(), entries)); + } + } + } + + // Notebook doesn't exist, create it + use weaver_api::sh_weaver::actor::Author; + let path = normalize_title_path(title); + let author = Author::new().did(author_did.clone()).build(); + let book = Book::new() + .authors(vec![author]) + .entry_list(vec![]) + .maybe_title(Some(title.into())) + .maybe_path(Some(path.into())) + .maybe_created_at(Some(jacquard::types::string::Datetime::now())) + .build(); + + let response = self.create_record(book, None).await?; + Ok((response.uri, Vec::new())) + } + + async fn upsert_entry( + &self, + notebook_title: &str, + entry_title: &str, + entry: entry::Entry<'_>, + ) -> Result<(AtUri<'static>, bool), WeaverError> { + // Get our own DID + let (did, _) = self.info().await.ok_or_else(|| { + AgentError::from(ClientError::invalid_request("No session info available")) + })?; + + // Find or create notebook + let (notebook_uri, entry_refs) = self.upsert_notebook(notebook_title, &did).await?; + + // Check if entry with this title exists in the notebook + for entry_ref in &entry_refs { + let existing = self + .get_record::(&entry_ref.uri) + .await + .map_err(|e| AgentError::from(ClientError::from(e)))?; + if let Ok(existing_entry) = existing.parse() { + if existing_entry.value.title == entry_title { + // Update existing entry + self.update_record::(&entry_ref.uri, |e| { + e.content = entry.content.clone(); + e.embeds = entry.embeds.clone(); + e.tags = entry.tags.clone(); + }) + .await?; + return Ok((entry_ref.uri.clone().into_static(), false)); + } + } + } + + // Entry doesn't exist, create it + let response = self.create_record(entry, None).await?; + let entry_uri = response.uri.clone(); + + // Add to notebook's entry_list + use weaver_api::sh_weaver::notebook::book::Book; + let new_ref = StrongRef::new().uri(response.uri).cid(response.cid).build(); + + self.update_record::(¬ebook_uri, |book| { + book.entry_list.push(new_ref); + }) + .await?; + + Ok((entry_uri, true)) + } + + async fn view_notebook( + &self, + uri: &AtUri<'_>, + ) -> Result<(NotebookView<'static>, Vec>), WeaverError> { + use jacquard::to_data; + use weaver_api::sh_weaver::notebook::AuthorListView; + use weaver_api::sh_weaver::notebook::book::Book; + + let notebook = self + .get_record::(uri) + .await + .map_err(|e| AgentError::from(e))? + .into_output() + .map_err(|_| { + AgentError::from(ClientError::invalid_request("Failed to parse Book record")) + })?; + + let title = notebook.value.title.clone(); + let tags = notebook.value.tags.clone(); + + let mut authors = Vec::new(); + + for (index, author) in notebook.value.authors.iter().enumerate() { + let (profile_uri, profile_view) = self.hydrate_profile_view(&author.did).await?; + authors.push( + AuthorListView::new() + .maybe_uri(profile_uri) + .record(profile_view) + .index(index as i64) + .build(), + ); + } + let entries = notebook + .value + .entry_list + .iter() + .cloned() + .map(IntoStatic::into_static) + .collect(); + + Ok(( + NotebookView::new() + .cid(notebook.cid.ok_or_else(|| { + AgentError::from(ClientError::invalid_request("Notebook missing CID")) + })?) + .uri(notebook.uri) + .indexed_at(jacquard::types::string::Datetime::now()) + .maybe_title(title) + .maybe_tags(tags) + .authors(authors) + .record(to_data(¬ebook.value).map_err(|_| { + AgentError::from(ClientError::invalid_request("Failed to serialize notebook")) + })?) + .build(), + entries, + )) + } + + async fn fetch_entry_view<'a>( + &self, + notebook: &NotebookView<'a>, + entry_ref: &StrongRef<'_>, + ) -> Result, WeaverError> { + use jacquard::to_data; + use weaver_api::sh_weaver::notebook::entry::Entry; + + let entry_uri = Entry::uri(entry_ref.uri.clone()) + .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid entry URI")))?; + let entry = self.fetch_record(&entry_uri).await?; + + let title = entry.value.title.clone(); + let tags = entry.value.tags.clone(); + + Ok(EntryView::new() + .cid(entry.cid.ok_or_else(|| { + AgentError::from(ClientError::invalid_request("Entry missing CID")) + })?) + .uri(entry.uri) + .indexed_at(jacquard::types::string::Datetime::now()) + .record(to_data(&entry.value).map_err(|_| { + AgentError::from(ClientError::invalid_request("Failed to serialize entry")) + })?) + .maybe_tags(tags) + .title(title) + .authors(notebook.authors.clone()) + .build()) + } + + async fn entry_by_title<'a>( + &self, + notebook: &NotebookView<'a>, + entries: &[StrongRef<'_>], + title: &str, + ) -> Result, entry::Entry<'a>)>, WeaverError> { + use weaver_api::sh_weaver::notebook::BookEntryRef; + use weaver_api::sh_weaver::notebook::entry::Entry; + + for (index, entry_ref) in entries.iter().enumerate() { + let resp = self + .get_record::(&entry_ref.uri) + .await + .map_err(|e| AgentError::from(e))?; + if let Ok(entry) = resp.parse() { + if entry.value.path == title || entry.value.title == title { + // Build BookEntryView with prev/next + let entry_view = self.fetch_entry_view(notebook, entry_ref).await?; + + let prev_entry = if index > 0 { + let prev_entry_ref = &entries[index - 1]; + self.fetch_entry_view(notebook, prev_entry_ref).await.ok() + } else { + None + } + .map(|e| BookEntryRef::new().entry(e).build()); + + let next_entry = if index < entries.len() - 1 { + let next_entry_ref = &entries[index + 1]; + self.fetch_entry_view(notebook, next_entry_ref).await.ok() + } else { + None + } + .map(|e| BookEntryRef::new().entry(e).build()); + + let book_entry_view = BookEntryView::new() + .entry(entry_view) + .maybe_next(next_entry) + .maybe_prev(prev_entry) + .index(index as i64) + .build(); + + return Ok(Some((book_entry_view, entry.value.into_static()))); + } + } + } + Ok(None) + } + + async fn notebook_by_title( + &self, + ident: &jacquard::types::ident::AtIdentifier<'_>, + title: &str, + ) -> Result, Vec>)>, WeaverError> { + use jacquard::types::collection::Collection; + use jacquard::types::nsid::Nsid; + use jacquard::xrpc::XrpcExt; + use weaver_api::com_atproto::repo::list_records::ListRecords; + use weaver_api::sh_weaver::notebook::AuthorListView; + use weaver_api::sh_weaver::notebook::book::Book; + + let (repo_did, pds_url) = match ident { + jacquard::types::ident::AtIdentifier::Did(did) => { + let pds = self.pds_for_did(did).await.map_err(|e| { + AgentError::from( + ClientError::from(e).with_context("Failed to resolve PDS for DID"), + ) + })?; + (did.clone(), pds) + } + jacquard::types::ident::AtIdentifier::Handle(handle) => { + self.pds_for_handle(handle).await.map_err(|e| { + AgentError::from(ClientError::from(e).with_context("Failed to resolve handle")) + })? + } + }; + + // TODO: use the cursor to search through all records with this NSID for the repo + let resp = self + .xrpc(pds_url) + .send( + &ListRecords::new() + .repo(repo_did) + .collection(Nsid::raw(Book::NSID)) + .limit(100) + .build(), + ) + .await + .map_err(|e| AgentError::from(ClientError::from(e)))?; + + if let Ok(list) = resp.parse() { + for record in list.records { + let notebook: Book = jacquard::from_data(&record.value).map_err(|_| { + AgentError::from(ClientError::invalid_request( + "Failed to parse notebook record", + )) + })?; + if let Some(book_title) = notebook.path + && book_title == title + { + let tags = notebook.tags.clone(); + + let mut authors = Vec::new(); + + for (index, author) in notebook.authors.iter().enumerate() { + let (profile_uri, profile_view) = + self.hydrate_profile_view(&author.did).await?; + authors.push( + AuthorListView::new() + .maybe_uri(profile_uri) + .record(profile_view) + .index(index as i64) + .build(), + ); + } + let entries = notebook + .entry_list + .iter() + .cloned() + .map(IntoStatic::into_static) + .collect(); + + return Ok(Some(( + NotebookView::new() + .cid(record.cid) + .uri(record.uri) + .indexed_at(jacquard::types::string::Datetime::now()) + .title(book_title) + .maybe_tags(tags) + .authors(authors) + .record(record.value.clone()) + .build() + .into_static(), + entries, + ))); + } else if let Some(book_title) = notebook.title + && book_title == title + { + let tags = notebook.tags.clone(); + + let mut authors = Vec::new(); + + for (index, author) in notebook.authors.iter().enumerate() { + let (profile_uri, profile_view) = + self.hydrate_profile_view(&author.did).await?; + authors.push( + AuthorListView::new() + .maybe_uri(profile_uri) + .record(profile_view) + .index(index as i64) + .build(), + ); + } + let entries = notebook + .entry_list + .iter() + .cloned() + .map(IntoStatic::into_static) + .collect(); + + return Ok(Some(( + NotebookView::new() + .cid(record.cid) + .uri(record.uri) + .indexed_at(jacquard::types::string::Datetime::now()) + .title(book_title) + .maybe_tags(tags) + .authors(authors) + .record(record.value.clone()) + .build() + .into_static(), + entries, + ))); + } + } + } + + Ok(None) + } + + async fn confirm_record_ref(&self, uri: &AtUri<'_>) -> Result, WeaverError> { + let rkey = uri.rkey().ok_or_else(|| { + AgentError::from( + ClientError::invalid_request("AtUri missing rkey") + .with_help("ensure the URI includes a record key after the collection"), + ) + })?; + + // Resolve authority (DID or handle) to get DID and PDS + use jacquard::types::ident::AtIdentifier; + let (repo_did, pds_url) = match uri.authority() { + AtIdentifier::Did(did) => { + let pds = self.pds_for_did(did).await.map_err(|e| { + AgentError::from( + ClientError::from(e) + .with_context("DID document resolution failed during record retrieval"), + ) + })?; + (did.clone(), pds) + } + AtIdentifier::Handle(handle) => self.pds_for_handle(handle).await.map_err(|e| { + AgentError::from( + ClientError::from(e) + .with_context("handle resolution failed during record retrieval"), + ) + })?, + }; + + // Make stateless XRPC call to that PDS (no auth required for public records) + use weaver_api::com_atproto::repo::get_record::GetRecord; + let request = GetRecord::new() + .repo(AtIdentifier::Did(repo_did)) + .collection( + uri.collection() + .expect("collection should exist if rkey does") + .clone(), + ) + .rkey(rkey.clone()) + .build(); + + let response: Response = { + let http_request = xrpc::build_http_request(&pds_url, &request, &self.opts().await) + .map_err(|e| AgentError::from(ClientError::transport(e)))?; + + let http_response = self + .send_http(http_request) + .await + .map_err(|e| AgentError::from(ClientError::transport(e)))?; + + xrpc::process_response(http_response) + } + .map_err(|e| AgentError::new(AgentErrorKind::Client, Some(e.into())))?; + let record = response.parse().map_err(|e| AgentError::xrpc(e))?; + let strong_ref = StrongRef::new() + .uri(record.uri) + .cid(record.cid.expect("when does this NOT have a CID?")) + .build(); + Ok(strong_ref.into_static()) + } + + async fn hydrate_profile_view( + &self, + did: &Did<'_>, + ) -> Result< + ( + Option>, + weaver_api::sh_weaver::actor::ProfileDataView<'static>, + ), + WeaverError, + > { + use weaver_api::app_bsky::actor::{ + ProfileViewDetailed, get_profile::GetProfile, profile::Profile as BskyProfile, + }; + use weaver_api::sh_weaver::actor::{ + ProfileDataView, ProfileDataViewInner, ProfileView, profile::Profile as WeaverProfile, + }; + + let handles = self.resolve_did_doc_owned(&did).await?.handles(); + let handle = handles.first().ok_or_else(|| { + AgentError::from(ClientError::invalid_request("couldn't resolve handle")) + })?; + + // Try weaver profile first + let weaver_uri = WeaverProfile::uri(format!("at://{}/sh.weaver.actor.profile/self", did)) + .map_err(|_| { + AgentError::from(ClientError::invalid_request("Invalid weaver profile URI")) + })?; + if let Ok(weaver_record) = self.fetch_record(&weaver_uri).await { + // Convert blobs to CDN URLs + let avatar = weaver_record + .value + .avatar + .as_ref() + .map(|blob| { + let cid = blob.blob().cid(); + jacquard::types::string::Uri::new_owned(format!( + "https://cdn.bsky.app/img/avatar/plain/{}/{}", + did, cid + )) + }) + .transpose() + .map_err(|_| { + AgentError::from(ClientError::invalid_request("Invalid avatar URI")) + })?; + let banner = weaver_record + .value + .banner + .as_ref() + .map(|blob| { + let cid = blob.blob().cid(); + jacquard::types::string::Uri::new_owned(format!( + "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}", + did, cid + )) + }) + .transpose() + .map_err(|_| { + AgentError::from(ClientError::invalid_request("Invalid banner URI")) + })?; + + let profile_view = ProfileView::new() + .did(did.clone()) + .handle(handle.clone()) + .maybe_display_name(weaver_record.value.display_name.clone()) + .maybe_description(weaver_record.value.description.clone()) + .maybe_avatar(avatar) + .maybe_banner(banner) + .maybe_location(weaver_record.value.location.clone()) + .maybe_links(weaver_record.value.links.clone()) + .maybe_pronouns(weaver_record.value.pronouns.clone()) + .maybe_pinned(weaver_record.value.pinned.clone()) + .indexed_at(jacquard::types::string::Datetime::now()) + .maybe_created_at(weaver_record.value.created_at) + .build(); + + return Ok(( + Some(weaver_uri.as_uri().clone().into_static()), + ProfileDataView::new() + .inner(ProfileDataViewInner::ProfileView(Box::new(profile_view))) + .build() + .into_static(), + )); + } + + if let Ok(bsky_resp) = self + .send(GetProfile::new().actor(did.clone()).build()) + .await + { + if let Ok(output) = bsky_resp.parse() { + let bsky_uri = BskyProfile::uri(format!( + "at://{}/app.bsky.actor.profile/self", + did + )) + .map_err(|_| { + AgentError::from(ClientError::invalid_request("Invalid bsky profile URI")) + })?; + return Ok(( + Some(bsky_uri.as_uri().clone().into_static()), + ProfileDataView::new() + .inner(ProfileDataViewInner::ProfileViewDetailed(Box::new( + output.value.into_static(), + ))) + .build() + .into_static(), + )); + } + } + + // Fallback: fetch bsky profile record directly and construct minimal ProfileViewDetailed + let bsky_uri = BskyProfile::uri(format!("at://{}/app.bsky.actor.profile/self", did)) + .map_err(|_| { + AgentError::from(ClientError::invalid_request("Invalid bsky profile URI")) + })?; + let bsky_record = self.fetch_record(&bsky_uri).await?; + + let avatar = bsky_record + .value + .avatar + .as_ref() + .map(|blob| { + let cid = blob.blob().cid(); + jacquard::types::string::Uri::new_owned(format!( + "https://cdn.bsky.app/img/avatar/plain/{}/{}", + did, cid + )) + }) + .transpose() + .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid avatar URI")))?; + let banner = bsky_record + .value + .banner + .as_ref() + .map(|blob| { + let cid = blob.blob().cid(); + jacquard::types::string::Uri::new_owned(format!( + "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}", + did, cid + )) + }) + .transpose() + .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid banner URI")))?; + + let profile_detailed = ProfileViewDetailed::new() + .did(did.clone()) + .handle(handle.clone()) + .maybe_display_name(bsky_record.value.display_name.clone()) + .maybe_description(bsky_record.value.description.clone()) + .maybe_avatar(avatar) + .maybe_banner(banner) + .indexed_at(jacquard::types::string::Datetime::now()) + .maybe_created_at(bsky_record.value.created_at) + .build(); + + Ok(( + Some(bsky_uri.as_uri().clone().into_static()), + ProfileDataView::new() + .inner(ProfileDataViewInner::ProfileViewDetailed(Box::new( + profile_detailed, + ))) + .build() + .into_static(), + )) + } + + async fn view_entry<'a>( + &self, + notebook: &NotebookView<'a>, + entries: &[StrongRef<'_>], + index: usize, + ) -> Result, WeaverError> { + use weaver_api::sh_weaver::notebook::BookEntryRef; + + let entry_ref = entries + .get(index) + .ok_or_else(|| AgentError::from(ClientError::invalid_request("entry out of bounds")))?; + let entry = self.fetch_entry_view(notebook, entry_ref).await?; + + let prev_entry = if index > 0 { + let prev_entry_ref = &entries[index - 1]; + self.fetch_entry_view(notebook, prev_entry_ref).await.ok() + } else { + None + } + .map(|e| BookEntryRef::new().entry(e).build()); + + let next_entry = if index < entries.len() - 1 { + let next_entry_ref = &entries[index + 1]; + self.fetch_entry_view(notebook, next_entry_ref).await.ok() + } else { + None + } + .map(|e| BookEntryRef::new().entry(e).build()); + + Ok(BookEntryView::new() + .entry(entry) + .maybe_next(next_entry) + .maybe_prev(prev_entry) + .index(index as i64) + .build()) + } + + async fn view_page<'a>( + &self, + notebook: &NotebookView<'a>, + pages: &[StrongRef<'_>], + index: usize, + ) -> Result, WeaverError> { + use weaver_api::sh_weaver::notebook::BookEntryRef; + + let entry_ref = pages + .get(index) + .ok_or_else(|| AgentError::from(ClientError::invalid_request("entry out of bounds")))?; + let entry = self.fetch_page_view(notebook, entry_ref).await?; + + let prev_entry = if index > 0 { + let prev_entry_ref = &pages[index - 1]; + self.fetch_page_view(notebook, prev_entry_ref).await.ok() + } else { + None + } + .map(|e| BookEntryRef::new().entry(e).build()); + + let next_entry = if index < pages.len() - 1 { + let next_entry_ref = &pages[index + 1]; + self.fetch_page_view(notebook, next_entry_ref).await.ok() + } else { + None + } + .map(|e| BookEntryRef::new().entry(e).build()); + + Ok(BookEntryView::new() + .entry(entry) + .maybe_next(next_entry) + .maybe_prev(prev_entry) + .index(index as i64) + .build()) + } + + async fn fetch_page_view<'a>( + &self, + notebook: &NotebookView<'a>, + entry_ref: &StrongRef<'_>, + ) -> Result, WeaverError> { + use jacquard::to_data; + use weaver_api::sh_weaver::notebook::page::Page; + + let entry_uri = Page::uri(entry_ref.uri.clone()) + .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid page URI")))?; + let entry = self.fetch_record(&entry_uri).await?; + + let title = entry.value.title.clone(); + let tags = entry.value.tags.clone(); + + Ok(EntryView::new() + .cid(entry.cid.ok_or_else(|| { + AgentError::from(ClientError::invalid_request("Page missing CID")) + })?) + .uri(entry.uri) + .indexed_at(jacquard::types::string::Datetime::now()) + .record(to_data(&entry.value).map_err(|_| { + AgentError::from(ClientError::invalid_request("Failed to serialize page")) + })?) + .maybe_tags(tags) + .title(title) + .authors(notebook.authors.clone()) + .build()) + } +} diff --git a/crates/weaver-common/src/error.rs b/crates/weaver-common/src/error.rs index f425d89..79d0dd2 100644 --- a/crates/weaver-common/src/error.rs +++ b/crates/weaver-common/src/error.rs @@ -12,6 +12,11 @@ pub enum WeaverError { #[diagnostic_source] Agent(#[from] jacquard::client::error::AgentError), + /// Jacquard Identity resolution error + #[error(transparent)] + #[diagnostic_source] + Identity(#[from] jacquard::identity::resolver::IdentityError), + /// Invalid notebook structure #[error("invalid notebook structure: {0}")] InvalidNotebook(String), @@ -49,8 +54,9 @@ pub enum WeaverError { /// Parse error with source code location information #[derive(thiserror::Error, Debug, Diagnostic)] -#[error("parse error")] -#[diagnostic()] +#[error("parse error: {}",self.kind)] +#[diagnostic(code(weaver::parse))] + pub struct ParseError { #[diagnostic_source] kind: ParseErrorKind, diff --git a/crates/weaver-common/src/lib.rs b/crates/weaver-common/src/lib.rs index 3ac8513..439c29c 100644 --- a/crates/weaver-common/src/lib.rs +++ b/crates/weaver-common/src/lib.rs @@ -1,30 +1,23 @@ //! Weaver common library - thin wrapper around jacquard with notebook-specific conveniences +pub mod agent; pub mod constellation; pub mod error; -pub mod view; pub mod worker_rt; // Re-export jacquard for convenience -pub use jacquard; -use jacquard::error::ClientError; -use jacquard::types::ident::AtIdentifier; -use jacquard::{CowStr, IntoStatic, xrpc}; - pub use error::WeaverError; -use jacquard::types::tid::{Ticker, Tid}; - +pub use jacquard; +use jacquard::CowStr; use jacquard::bytes::Bytes; -use jacquard::client::{Agent, AgentError, AgentErrorKind, AgentSession, AgentSessionExt}; +use jacquard::client::{Agent, AgentSession, AgentSessionExt}; use jacquard::prelude::*; -use jacquard::types::blob::{BlobRef, MimeType}; -use jacquard::types::string::{AtUri, Cid, Did, Handle, RecordKey}; -use jacquard::xrpc::Response; -use mime_sniffer::MimeTypeSniffer; +use jacquard::types::ident::AtIdentifier; +use jacquard::types::string::{AtUri, Cid, Did, Handle}; +use jacquard::types::tid::{Ticker, Tid}; use std::path::Path; use std::sync::LazyLock; use tokio::sync::Mutex; -use weaver_api::com_atproto::repo::get_record::GetRecordResponse; use weaver_api::com_atproto::repo::strong_ref::StrongRef; use weaver_api::sh_weaver::notebook::entry; use weaver_api::sh_weaver::publish::blob::Blob as PublishedBlob; @@ -109,22 +102,22 @@ pub trait WeaverExt: AgentSessionExt { fn view_notebook( &self, uri: &AtUri<'_>, - ) -> impl Future, Vec>), WeaverError>>; + ) -> impl Future, Vec>), WeaverError>>; /// Fetch an entry and construct EntryView fn fetch_entry_view<'a>( &self, - notebook: &view::NotebookView<'a>, + notebook: &agent::NotebookView<'a>, entry_ref: &StrongRef<'_>, - ) -> impl Future, WeaverError>>; + ) -> impl Future, WeaverError>>; /// Search for an entry by title within a notebook's entry list fn entry_by_title<'a>( &self, - notebook: &view::NotebookView<'a>, + notebook: &agent::NotebookView<'a>, entries: &[StrongRef<'_>], title: &str, - ) -> impl Future, entry::Entry<'a>)>, WeaverError>>; + ) -> impl Future, entry::Entry<'a>)>, WeaverError>>; /// Search for a notebook by title for a given DID or handle fn notebook_by_title( @@ -133,470 +126,47 @@ pub trait WeaverExt: AgentSessionExt { title: &str, ) -> impl Future< Output = Result< - Option<(view::NotebookView<'static>, Vec>)>, + Option<(agent::NotebookView<'static>, Vec>)>, WeaverError, >, >; -} - -impl WeaverExt for Agent { - async fn publish_notebook(&self, _path: &Path) -> Result, WeaverError> { - // TODO: Implementation - todo!("publish_notebook not yet implemented") - } - async fn publish_blob<'a>( + /// Hydrate a profile view from either weaver or bsky profile + fn hydrate_profile_view( &self, - blob: Bytes, - url_path: &'a str, - prev: Option, - ) -> Result<(StrongRef<'a>, PublishedBlob<'a>), WeaverError> { - let mime_type = - MimeType::new_owned(blob.sniff_mime_type().unwrap_or("application/octet-stream")); - - let blob = self.upload_blob(blob, mime_type).await?; - let publish_record = PublishedBlob::new() - .path(url_path) - .upload(BlobRef::Blob(blob)) - .build(); - let tid = W_TICKER.lock().await.next(prev); - let record = self - .create_record(publish_record.clone(), Some(RecordKey::any(tid.as_str())?)) - .await?; - let strong_ref = StrongRef::new().uri(record.uri).cid(record.cid).build(); - - Ok((strong_ref, publish_record)) - } - - async fn upsert_notebook( - &self, - title: &str, - author_did: &Did<'_>, - ) -> Result<(AtUri<'static>, Vec>), WeaverError> { - use jacquard::types::collection::Collection; - use jacquard::types::nsid::Nsid; - use jacquard::xrpc::XrpcExt; - use weaver_api::com_atproto::repo::list_records::ListRecords; - use weaver_api::sh_weaver::notebook::book::Book; - - // Find the PDS for this DID - let pds_url = self.pds_for_did(author_did).await.map_err(|e| { - AgentError::from(ClientError::from(e).with_context("Failed to resolve PDS for DID")) - })?; - - // Search for existing notebook with this title - let resp = self - .xrpc(pds_url) - .send( - &ListRecords::new() - .repo(author_did.clone()) - .collection(Nsid::raw(Book::NSID)) - .limit(100) - .build(), - ) - .await - .map_err(|e| AgentError::from(ClientError::from(e)))?; - - if let Ok(list) = resp.parse() { - for record in list.records { - let notebook: Book = jacquard::from_data(&record.value).map_err(|_| { - AgentError::from(ClientError::invalid_request( - "Failed to parse notebook record", - )) - })?; - if let Some(book_title) = notebook.title - && book_title == title - { - let entries = notebook - .entry_list - .iter() - .cloned() - .map(IntoStatic::into_static) - .collect(); - return Ok((record.uri.into_static(), entries)); - } - } - } - - // Notebook doesn't exist, create it - use weaver_api::sh_weaver::actor::Author; - let author = Author::new().did(author_did.clone()).build(); - let book = Book::new() - .authors(vec![author]) - .entry_list(vec![]) - .maybe_title(Some(title.into())) - .maybe_created_at(Some(jacquard::types::string::Datetime::now())) - .build(); - - let response = self.create_record(book, None).await?; - Ok((response.uri, Vec::new())) - } + did: &Did<'_>, + ) -> impl Future< + Output = Result< + ( + Option>, + weaver_api::sh_weaver::actor::ProfileDataView<'static>, + ), + WeaverError, + >, + >; - async fn upsert_entry( + /// View an entry at a specific index with prev/next navigation + fn view_entry<'a>( &self, - notebook_title: &str, - entry_title: &str, - entry: entry::Entry<'_>, - ) -> Result<(AtUri<'static>, bool), WeaverError> { - // Get our own DID - let (did, _) = self.info().await.ok_or_else(|| { - AgentError::from(ClientError::invalid_request("No session info available")) - })?; - - // Find or create notebook - let (notebook_uri, entry_refs) = self.upsert_notebook(notebook_title, &did).await?; - - // Check if entry with this title exists in the notebook - for entry_ref in &entry_refs { - let existing = self - .get_record::(&entry_ref.uri) - .await - .map_err(|e| AgentError::from(ClientError::from(e)))?; - if let Ok(existing_entry) = existing.parse() { - if existing_entry.value.title == entry_title { - // Update existing entry - self.update_record::(&entry_ref.uri, |e| { - e.content = entry.content.clone(); - e.embeds = entry.embeds.clone(); - e.tags = entry.tags.clone(); - }) - .await?; - return Ok((entry_ref.uri.clone().into_static(), false)); - } - } - } - - // Entry doesn't exist, create it - let response = self.create_record(entry, None).await?; - let entry_uri = response.uri.clone(); - - // Add to notebook's entry_list - use weaver_api::sh_weaver::notebook::book::Book; - let new_ref = StrongRef::new().uri(response.uri).cid(response.cid).build(); - - self.update_record::(¬ebook_uri, |book| { - book.entry_list.push(new_ref); - }) - .await?; - - Ok((entry_uri, true)) - } + notebook: &agent::NotebookView<'a>, + entries: &[StrongRef<'_>], + index: usize, + ) -> impl Future, WeaverError>>; - async fn view_notebook( + /// View a page at a specific index with prev/next navigation + fn view_page<'a>( &self, - uri: &AtUri<'_>, - ) -> Result<(view::NotebookView<'static>, Vec>), WeaverError> { - use jacquard::to_data; - use weaver_api::app_bsky::actor::profile::Profile as BskyProfile; - use weaver_api::sh_weaver::notebook::AuthorListView; - use weaver_api::sh_weaver::notebook::book::Book; - - let notebook = self - .get_record::(uri) - .await - .map_err(|e| AgentError::from(e))? - .into_output() - .map_err(|_| { - AgentError::from(ClientError::invalid_request("Failed to parse Book record")) - })?; - - let title = notebook.value.title.clone(); - let tags = notebook.value.tags.clone(); - - let mut authors = Vec::new(); - - for (index, author) in notebook.value.authors.iter().enumerate() { - let author_uri = - BskyProfile::uri(format!("at://{}/app.bsky.actor.profile/self", author.did)) - .map_err(|_| { - AgentError::from(ClientError::invalid_request("Invalid author profile URI")) - })?; - let author_profile = self.fetch_record(&author_uri).await?; - - authors.push( - AuthorListView::new() - .uri(author_uri.as_uri().clone()) - .record(to_data(&author_profile).map_err(|_| { - AgentError::from(ClientError::invalid_request( - "Failed to serialize author profile", - )) - })?) - .index(index as i64) - .build(), - ); - } - let entries = notebook - .value - .entry_list - .iter() - .cloned() - .map(IntoStatic::into_static) - .collect(); - - Ok(( - view::NotebookView::new() - .cid(notebook.cid.ok_or_else(|| { - AgentError::from(ClientError::invalid_request("Notebook missing CID")) - })?) - .uri(notebook.uri) - .indexed_at(jacquard::types::string::Datetime::now()) - .maybe_title(title) - .maybe_tags(tags) - .authors(authors) - .record(to_data(¬ebook.value).map_err(|_| { - AgentError::from(ClientError::invalid_request("Failed to serialize notebook")) - })?) - .build(), - entries, - )) - } + notebook: &agent::NotebookView<'a>, + pages: &[StrongRef<'_>], + index: usize, + ) -> impl Future, WeaverError>>; - async fn fetch_entry_view<'a>( + /// Fetch a page view (like fetch_entry_view but for pages) + fn fetch_page_view<'a>( &self, - notebook: &view::NotebookView<'a>, + notebook: &agent::NotebookView<'a>, entry_ref: &StrongRef<'_>, - ) -> Result, WeaverError> { - use jacquard::to_data; - use weaver_api::sh_weaver::notebook::entry::Entry; - - let entry_uri = Entry::uri(entry_ref.uri.clone()) - .map_err(|_| AgentError::from(ClientError::invalid_request("Invalid entry URI")))?; - let entry = self.fetch_record(&entry_uri).await?; - - let title = entry.value.title.clone(); - let tags = entry.value.tags.clone(); - - Ok(view::EntryView::new() - .cid(entry.cid.ok_or_else(|| { - AgentError::from(ClientError::invalid_request("Entry missing CID")) - })?) - .uri(entry.uri) - .indexed_at(jacquard::types::string::Datetime::now()) - .record(to_data(&entry.value).map_err(|_| { - AgentError::from(ClientError::invalid_request("Failed to serialize entry")) - })?) - .maybe_tags(tags) - .title(title) - .authors(notebook.authors.clone()) - .build()) - } - - async fn entry_by_title<'a>( - &self, - notebook: &view::NotebookView<'a>, - entries: &[StrongRef<'_>], - title: &str, - ) -> Result, entry::Entry<'a>)>, WeaverError> { - use weaver_api::sh_weaver::notebook::BookEntryRef; - use weaver_api::sh_weaver::notebook::entry::Entry; - - for (index, entry_ref) in entries.iter().enumerate() { - let resp = self - .get_record::(&entry_ref.uri) - .await - .map_err(|e| AgentError::from(e))?; - if let Ok(entry) = resp.parse() { - if entry.value.title == title { - // Build BookEntryView with prev/next - let entry_view = self.fetch_entry_view(notebook, entry_ref).await?; - - let prev_entry = if index > 0 { - let prev_entry_ref = &entries[index - 1]; - self.fetch_entry_view(notebook, prev_entry_ref).await.ok() - } else { - None - } - .map(|e| BookEntryRef::new().entry(e).build()); - - let next_entry = if index < entries.len() - 1 { - let next_entry_ref = &entries[index + 1]; - self.fetch_entry_view(notebook, next_entry_ref).await.ok() - } else { - None - } - .map(|e| BookEntryRef::new().entry(e).build()); - - let book_entry_view = view::BookEntryView::new() - .entry(entry_view) - .maybe_next(next_entry) - .maybe_prev(prev_entry) - .index(index as i64) - .build(); - - return Ok(Some((book_entry_view, entry.value.into_static()))); - } - } - } - Ok(None) - } - - async fn notebook_by_title( - &self, - ident: &jacquard::types::ident::AtIdentifier<'_>, - title: &str, - ) -> Result, Vec>)>, WeaverError> { - use jacquard::to_data; - use jacquard::types::collection::Collection; - use jacquard::types::nsid::Nsid; - use jacquard::xrpc::XrpcExt; - use weaver_api::app_bsky::actor::profile::Profile as BskyProfile; - use weaver_api::com_atproto::repo::list_records::ListRecords; - use weaver_api::sh_weaver::notebook::AuthorListView; - use weaver_api::sh_weaver::notebook::book::Book; - - let (repo_did, pds_url) = match ident { - jacquard::types::ident::AtIdentifier::Did(did) => { - let pds = self.pds_for_did(did).await.map_err(|e| { - AgentError::from( - ClientError::from(e).with_context("Failed to resolve PDS for DID"), - ) - })?; - (did.clone(), pds) - } - jacquard::types::ident::AtIdentifier::Handle(handle) => { - self.pds_for_handle(handle).await.map_err(|e| { - AgentError::from(ClientError::from(e).with_context("Failed to resolve handle")) - })? - } - }; - - // TODO: use the cursor to search through all records with this NSID for the repo - let resp = self - .xrpc(pds_url) - .send( - &ListRecords::new() - .repo(repo_did) - .collection(Nsid::raw(Book::NSID)) - .limit(100) - .build(), - ) - .await - .map_err(|e| AgentError::from(ClientError::from(e)))?; - - if let Ok(list) = resp.parse() { - for record in list.records { - let notebook: Book = jacquard::from_data(&record.value).map_err(|_| { - AgentError::from(ClientError::invalid_request( - "Failed to parse notebook record", - )) - })?; - if let Some(book_title) = notebook.title - && book_title == title - { - let tags = notebook.tags.clone(); - - let mut authors = Vec::new(); - - for (index, author) in notebook.authors.iter().enumerate() { - let author_uri = BskyProfile::uri(format!( - "at://{}/app.bsky.actor.profile/self", - author.did - )) - .map_err(|_| { - AgentError::from(ClientError::invalid_request( - "Invalid author profile URI", - )) - })?; - let author_profile = self.fetch_record(&author_uri).await?; - - authors.push( - AuthorListView::new() - .uri(author_uri.as_uri().clone()) - .record(to_data(&author_profile).map_err(|_| { - AgentError::from(ClientError::invalid_request( - "Failed to serialize author profile", - )) - })?) - .index(index as i64) - .build(), - ); - } - let entries = notebook - .entry_list - .iter() - .cloned() - .map(IntoStatic::into_static) - .collect(); - - return Ok(Some(( - view::NotebookView::new() - .cid(record.cid) - .uri(record.uri) - .indexed_at(jacquard::types::string::Datetime::now()) - .title(book_title) - .maybe_tags(tags) - .authors(authors) - .record(record.value.clone()) - .build() - .into_static(), - entries, - ))); - } - } - } - - Ok(None) - } - - async fn confirm_record_ref(&self, uri: &AtUri<'_>) -> Result, WeaverError> { - let rkey = uri.rkey().ok_or_else(|| { - AgentError::from( - ClientError::invalid_request("AtUri missing rkey") - .with_help("ensure the URI includes a record key after the collection"), - ) - })?; - - // Resolve authority (DID or handle) to get DID and PDS - use jacquard::types::ident::AtIdentifier; - let (repo_did, pds_url) = match uri.authority() { - AtIdentifier::Did(did) => { - let pds = self.pds_for_did(did).await.map_err(|e| { - AgentError::from( - ClientError::from(e) - .with_context("DID document resolution failed during record retrieval"), - ) - })?; - (did.clone(), pds) - } - AtIdentifier::Handle(handle) => self.pds_for_handle(handle).await.map_err(|e| { - AgentError::from( - ClientError::from(e) - .with_context("handle resolution failed during record retrieval"), - ) - })?, - }; - - // Make stateless XRPC call to that PDS (no auth required for public records) - use weaver_api::com_atproto::repo::get_record::GetRecord; - let request = GetRecord::new() - .repo(AtIdentifier::Did(repo_did)) - .collection( - uri.collection() - .expect("collection should exist if rkey does") - .clone(), - ) - .rkey(rkey.clone()) - .build(); - - let response: Response = { - let http_request = xrpc::build_http_request(&pds_url, &request, &self.opts().await) - .map_err(|e| AgentError::from(ClientError::transport(e)))?; - - let http_response = self - .send_http(http_request) - .await - .map_err(|e| AgentError::from(ClientError::transport(e)))?; - - xrpc::process_response(http_response) - } - .map_err(|e| AgentError::new(AgentErrorKind::Client, Some(e.into())))?; - let record = response.parse().map_err(|e| AgentError::xrpc(e))?; - let strong_ref = StrongRef::new() - .uri(record.uri) - .cid(record.cid.expect("when does this NOT have a CID?")) - .build(); - Ok(strong_ref.into_static()) - } + ) -> impl Future, WeaverError>>; } /// Result of publishing a notebook @@ -771,3 +341,7 @@ impl<'a> LinkUri<'a> { LinkUri::Path(markdown_weaver::CowStr::Borrowed(dest_url)) } } + +pub fn normalize_title_path(title: &str) -> String { + title.replace(' ', "_").to_lowercase() +} diff --git a/crates/weaver-common/src/view.rs b/crates/weaver-common/src/view.rs deleted file mode 100644 index 0b8da3c..0000000 --- a/crates/weaver-common/src/view.rs +++ /dev/null @@ -1,286 +0,0 @@ -use std::sync::Arc; - -use jacquard::{ - IntoStatic, - client::{AgentSessionExt, BasicClient}, - from_data, - prelude::IdentityResolver, - to_data, - types::{ - aturi::AtUri, collection::Collection, ident::AtIdentifier, nsid::Nsid, - string::Datetime, - }, - xrpc::XrpcExt, -}; -use miette::{IntoDiagnostic, Result}; -use weaver_api::{ - app_bsky::actor::profile::Profile as BskyProfile, - com_atproto::repo::{list_records::ListRecords, strong_ref::StrongRef}, - sh_weaver::notebook::{book::Book, entry::Entry, page::Page}, -}; - -// Re-export view types for use elsewhere -pub use weaver_api::sh_weaver::notebook::{ - AuthorListView, BookEntryRef, BookEntryView, EntryView, NotebookView, -}; - -pub async fn view_notebook( - client: Arc, - uri: &AtUri<'_>, -) -> Result<(NotebookView<'static>, Vec>)> { - let notebook = client.get_record::(uri).await?.into_output()?; - - let title = notebook.value.title.clone(); - let tags = notebook.value.tags.clone(); - - let mut authors = Vec::new(); - - for (index, author) in notebook.value.authors.iter().enumerate() { - // TODO: swap to using weaver profiles here, or pick between them - let author_uri = - BskyProfile::uri(format!("at://{}/app.bsky.actor.profile/self", author.did))?; - let author_profile = client.fetch_record(&author_uri).await?; - - authors.push( - AuthorListView::new() - .uri(author_uri.as_uri().clone()) - .record(to_data(&author_profile)?) - .index(index as i64) - .build(), - ); - } - let entries = notebook - .value - .entry_list - .iter() - .cloned() - .map(IntoStatic::into_static) - .collect(); - - Ok(( - NotebookView::new() - .cid(notebook.cid.unwrap()) - .uri(notebook.uri) - .indexed_at(Datetime::now()) - .maybe_title(title) - .maybe_tags(tags) - .authors(authors) - .record(to_data(¬ebook.value)?) - .build(), - entries, - )) -} - -pub async fn fetch_entry_view<'a>( - client: Arc, - notebook: &NotebookView<'a>, - entry_ref: &StrongRef<'_>, -) -> Result> { - let entry = client - .fetch_record(&Entry::uri(entry_ref.uri.clone())?) - .await?; - - let title = entry.value.title.clone(); - let tags = entry.value.tags.clone(); - - Ok(EntryView::new() - .cid(entry.cid.unwrap()) - .uri(entry.uri) - .indexed_at(Datetime::now()) - .record(to_data(&entry.value)?) - .maybe_tags(tags) - .title(title) - .authors(notebook.authors.clone()) - .build()) -} - -pub async fn view_entry<'a>( - client: Arc, - notebook: &NotebookView<'a>, - entries: &[StrongRef<'_>], - index: usize, -) -> Result> { - let entry_ref = entries - .get(index) - .ok_or(miette::miette!("entry out of bounds"))?; - let entry = fetch_entry_view(client.clone(), notebook, entry_ref).await?; - let prev_entry = if index > 0 { - let prev_entry_ref = entries[index - 1].clone(); - fetch_entry_view(client.clone(), notebook, &prev_entry_ref) - .await - .ok() - } else { - None - } - .map(|e| BookEntryRef::new().entry(e).build()); - let next_entry = if index < entries.len() - 1 { - let next_entry_ref = entries[index + 1].clone(); - fetch_entry_view(client.clone(), notebook, &next_entry_ref) - .await - .ok() - } else { - None - } - .map(|e| BookEntryRef::new().entry(e).build()); - Ok(BookEntryView::new() - .entry(entry) - .maybe_next(next_entry) - .maybe_prev(prev_entry) - .index(index as i64) - .build()) -} - -pub async fn fetch_page_view<'a>( - client: Arc, - notebook: &NotebookView<'a>, - entry_ref: &StrongRef<'_>, -) -> Result> { - let entry = client - .fetch_record(&Page::uri(entry_ref.uri.clone())?) - .await?; - - let title = entry.value.title.clone(); - let tags = entry.value.tags.clone(); - - Ok(EntryView::new() - .cid(entry.cid.unwrap()) - .uri(entry.uri) - .indexed_at(Datetime::now()) - .record(to_data(&entry.value)?) - .maybe_tags(tags) - .title(title) - .authors(notebook.authors.clone()) - .build()) -} - -pub async fn view_page<'a>( - client: Arc, - notebook: &NotebookView<'a>, - pages: &[StrongRef<'_>], - index: usize, -) -> Result> { - let entry_ref = pages - .get(index) - .ok_or(miette::miette!("entry out of bounds"))?; - let entry = fetch_page_view(client.clone(), notebook, entry_ref).await?; - let prev_entry = if index > 0 { - let prev_entry_ref = pages[index - 1].clone(); - fetch_page_view(client.clone(), notebook, &prev_entry_ref) - .await - .ok() - } else { - None - } - .map(|e| BookEntryRef::new().entry(e).build()); - let next_entry = if index < pages.len() - 1 { - let next_entry_ref = pages[index + 1].clone(); - fetch_page_view(client.clone(), notebook, &next_entry_ref) - .await - .ok() - } else { - None - } - .map(|e| BookEntryRef::new().entry(e).build()); - Ok(BookEntryView::new() - .entry(entry) - .maybe_next(next_entry) - .maybe_prev(prev_entry) - .index(index as i64) - .build()) -} - -pub async fn entry_by_title<'a>( - client: Arc, - notebook: &NotebookView<'a>, - entries: &[StrongRef<'_>], - title: &str, -) -> Result, Entry<'a>)>> { - for (index, entry_ref) in entries.iter().enumerate() { - let resp = client.get_record::(&entry_ref.uri).await?; - if let Ok(entry) = resp.parse() - && entry.value.title == title - { - return Ok(Some(( - view_entry(client.clone(), notebook, entries, index).await?, - entry.value.into_static(), - ))); - } - } - Ok(None) -} - -pub async fn notebook_by_title<'a>( - client: Arc, - ident: &AtIdentifier<'_>, - title: &str, -) -> Result, Vec>)>> { - let (repo_did, pds_url) = match ident { - AtIdentifier::Did(did) => { - let pds = client.pds_for_did(did).await?; - (did.clone(), pds) - } - AtIdentifier::Handle(handle) => client.pds_for_handle(handle).await?, - }; - // TODO: use the cursor to search through all records with this NSID for the repo - let resp = client - .xrpc(pds_url) - .send( - &ListRecords::new() - .repo(repo_did) - .collection(Nsid::raw(Book::NSID)) - .limit(100) - .build(), - ) - .await?; - if let Ok(list) = resp.parse() { - for record in list.records { - let notebook: Book = from_data(&record.value).into_diagnostic()?; - if let Some(book_title) = notebook.title - && book_title == title - { - let tags = notebook.tags.clone(); - - let mut authors = Vec::new(); - - for (index, author) in notebook.authors.iter().enumerate() { - // TODO: swap to using weaver profiles here, or pick between them - let author_uri = BskyProfile::uri(format!( - "at://{}/app.bsky.actor.profile/self", - author.did - ))?; - let author_profile = client.fetch_record(&author_uri).await?; - - authors.push( - AuthorListView::new() - .uri(author_uri.as_uri().clone()) - .record(to_data(&author_profile)?) - .index(index as i64) - .build(), - ); - } - let entries = notebook - .entry_list - .iter() - .cloned() - .map(IntoStatic::into_static) - .collect(); - - return Ok(Some(( - NotebookView::new() - .cid(record.cid) - .uri(record.uri) - .indexed_at(Datetime::now()) - .title(book_title) - .maybe_tags(tags) - .authors(authors) - .record(record.value.clone()) - .build() - .into_static(), - entries, - ))); - } - } - } - - Ok(None) -} diff --git a/crates/weaver-renderer/src/atproto/client.rs b/crates/weaver-renderer/src/atproto/client.rs index 8cac866..1e8922b 100644 --- a/crates/weaver-renderer/src/atproto/client.rs +++ b/crates/weaver-renderer/src/atproto/client.rs @@ -443,6 +443,7 @@ mod tests { fn test_client_context_creation() { let entry = Entry::new() .title("Test") + .path(weaver_common::normalize_title_path("Test")) .content("# Test") .created_at(Datetime::now()) .build(); diff --git a/lexicons/notebook/defs.json b/lexicons/notebook/defs.json index 46db449..37bee96 100644 --- a/lexicons/notebook/defs.json +++ b/lexicons/notebook/defs.json @@ -63,7 +63,10 @@ "required": ["record", "index"], "properties": { "uri": { "type": "string", "format": "at-uri" }, - "record": { "type": "unknown" }, + "record": { + "type": "ref", + "ref": "sh.weaver.actor.defs#profileDataView" + }, "index": { "type": "integer" } } }, diff --git a/lexicons/notebook/entry.json b/lexicons/notebook/entry.json index c0823fb..acca88e 100644 --- a/lexicons/notebook/entry.json +++ b/lexicons/notebook/entry.json @@ -8,9 +8,10 @@ "key": "tid", "record": { "type": "object", - "required": ["content", "title", "createdAt"], + "required": ["content", "title", "path", "createdAt"], "properties": { "title": { "type": "ref", "ref": "sh.weaver.notebook.defs#title" }, + "path": { "type": "ref", "ref": "sh.weaver.notebook.defs#path" }, "tags": { "type": "ref", "ref": "sh.weaver.notebook.defs#tags" }, "content": { -- knotmirror 0.0.1