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