atproto blogging
1//! Notebook settings panel - reusable editor with save/cancel logic.
2
3use crate::components::notebook::{
4 create_document_for_entry, delete_publication, document_exists, has_theme_customizations,
5 load_theme_values, publication_uri_for_notebook, sync_publication, sync_theme,
6};
7use crate::components::notebook_editor::{NotebookEditor, NotebookEditorMode, NotebookFormState};
8use crate::fetch::Fetcher;
9use dioxus::prelude::*;
10use jacquard::types::aturi::AtUri;
11use jacquard::types::string::Datetime;
12use weaver_api::com_atproto::repo::strong_ref::StrongRef;
13use weaver_api::sh_weaver::notebook::book::Book;
14
15/// Notebook settings panel with state management and save logic.
16///
17/// Renders the NotebookEditor form and handles save/cancel.
18/// Can be rendered inline or wrapped in a dialog by parent.
19#[component]
20pub fn NotebookSettingsPanel(
21 notebook_uri: AtUri<'static>,
22 book: Book<'static>,
23 #[props(default)] on_saved: Option<EventHandler<()>>,
24 #[props(default)] on_cancel: Option<EventHandler<()>>,
25) -> Element {
26 let fetcher = use_context::<Fetcher>();
27
28 let mut saving = use_signal(|| false);
29 let mut error = use_signal(|| None::<String>);
30
31 // Form state - initialized from book, theme loaded async.
32 let mut form_state = use_signal(|| NotebookFormState::from_book(&book));
33 let mut theme_loaded = use_signal(|| false);
34
35 // Load theme values on mount.
36 let book_for_theme = book.clone();
37 let theme_fetcher = fetcher.clone();
38 use_effect(move || {
39 if !theme_loaded() {
40 let fetcher = theme_fetcher.clone();
41 let book = book_for_theme.clone();
42
43 spawn(async move {
44 if let Some(theme_ref) = &book.theme {
45 match load_theme_values(&fetcher, theme_ref).await {
46 Ok(theme_values) => {
47 form_state.write().theme = theme_values;
48 }
49 Err(e) => {
50 tracing::warn!("Failed to load theme: {:?}", e);
51 }
52 }
53 }
54 theme_loaded.set(true);
55 });
56 }
57 });
58
59 // Save handler.
60 let notebook_uri_for_save = notebook_uri.clone();
61 let book_for_save = book.clone();
62 let save_fetcher = fetcher.clone();
63 let on_saved_handler = on_saved.clone();
64 let handle_save = move |new_state: NotebookFormState| {
65 let fetcher = save_fetcher.clone();
66 let notebook_uri = notebook_uri_for_save.clone();
67 let existing_book = book_for_save.clone();
68 let on_saved = on_saved_handler.clone();
69
70 spawn(async move {
71 use jacquard::CowStr;
72 use jacquard::client::AgentSessionExt;
73
74 saving.set(true);
75 error.set(None);
76
77 let now = Datetime::now();
78
79 let tags: Option<Vec<CowStr<'static>>> = if new_state.tags.is_empty() {
80 None
81 } else {
82 Some(
83 new_state
84 .tags
85 .iter()
86 .map(|s| CowStr::from(s.clone()))
87 .collect(),
88 )
89 };
90
91 let path: CowStr<'static> = new_state.path.clone().into();
92 let title: CowStr<'static> = new_state.title.clone().into();
93 let publish_global = new_state.publish_global;
94
95 // Sync theme if there are customizations.
96 let theme_ref: Option<StrongRef<'static>> =
97 if has_theme_customizations(&new_state.theme) {
98 match sync_theme(&fetcher, existing_book.theme.as_ref(), &new_state.theme).await
99 {
100 Ok(result) => Some(
101 StrongRef::new()
102 .uri(result.theme_uri)
103 .cid(result.theme_cid)
104 .build(),
105 ),
106 Err(e) => {
107 error.set(Some(format!("Failed to sync theme: {:?}", e)));
108 saving.set(false);
109 return;
110 }
111 }
112 } else {
113 existing_book.theme.clone()
114 };
115
116 let client = fetcher.get_client();
117 match client
118 .update_record::<Book>(¬ebook_uri, |book| {
119 book.title = Some(title.clone());
120 if !path.is_empty() {
121 book.path = Some(path.clone());
122 }
123 book.publish_global = Some(publish_global);
124 book.tags = tags.clone();
125 book.updated_at = Some(now.clone());
126 book.theme = theme_ref.clone();
127 })
128 .await
129 {
130 Ok(_output) => {
131 // Sync or delete publication based on publish_global.
132 if publish_global {
133 if let Err(e) = sync_publication(
134 &fetcher,
135 ¬ebook_uri,
136 &title,
137 &path,
138 &new_state.theme,
139 )
140 .await
141 {
142 tracing::warn!("Failed to sync publication: {:?}", e);
143 }
144
145 // Backfill documents if publishGlobal was just enabled.
146 let was_global = existing_book.publish_global.unwrap_or(false);
147 if !was_global {
148 if let Err(e) = backfill_documents(
149 &fetcher,
150 ¬ebook_uri,
151 &existing_book.entry_list,
152 )
153 .await
154 {
155 tracing::warn!("Failed to backfill documents: {:?}", e);
156 }
157 }
158 } else if let Err(e) = delete_publication(&fetcher, ¬ebook_uri).await {
159 tracing::warn!("Failed to delete publication: {:?}", e);
160 }
161
162 saving.set(false);
163 if let Some(handler) = &on_saved {
164 handler.call(());
165 }
166 }
167 Err(e) => {
168 error.set(Some(format!("Failed to save settings: {:?}", e)));
169 saving.set(false);
170 }
171 }
172 });
173 };
174
175 let on_cancel_handler = on_cancel.clone();
176 let handle_cancel = move |_| {
177 error.set(None);
178 if let Some(handler) = &on_cancel_handler {
179 handler.call(());
180 }
181 };
182
183 rsx! {
184 NotebookEditor {
185 mode: NotebookEditorMode::Edit,
186 initial_state: Some(form_state()),
187 on_save: handle_save,
188 on_cancel: handle_cancel,
189 saving: saving(),
190 error: error(),
191 }
192 }
193}
194
195/// Backfill site.standard.document records for all entries in a notebook.
196///
197/// Called when publishGlobal is toggled on for an existing notebook.
198/// Uses the book's entry_list and constellation backlinks to determine if backfill is needed.
199/// Entries are already indexed, so we just pass URIs to generateDocument (no inline records).
200async fn backfill_documents(
201 fetcher: &Fetcher,
202 notebook_uri: &AtUri<'_>,
203 entry_list: &[weaver_api::com_atproto::repo::strong_ref::StrongRef<'_>],
204) -> Result<(), weaver_common::WeaverError> {
205 use jacquard::prelude::*;
206 use jacquard::types::uri::Uri;
207 use weaver_api::sh_weaver::domain::generate_document::GenerateDocument;
208 use weaver_common::constellation::GetBacklinksQuery;
209
210 const CONSTELLATION_URL: &str = "https://constellation.microcosm.blue";
211
212 if entry_list.is_empty() {
213 tracing::info!("No entries to backfill");
214 return Ok(());
215 }
216
217 let publication_uri = publication_uri_for_notebook(notebook_uri).ok_or_else(|| {
218 weaver_common::WeaverError::InvalidNotebook("Could not build publication URI".into())
219 })?;
220
221 // Query constellation for existing documents that link to this publication.
222 let subject = Uri::new(publication_uri.as_str())
223 .map_err(|e| weaver_common::WeaverError::InvalidNotebook(e.to_string()))?;
224
225 let query = GetBacklinksQuery {
226 subject,
227 source: "site.standard.document:site".into(),
228 cursor: None,
229 did: vec![],
230 limit: 1000,
231 };
232
233 let constellation_url = jacquard::url::Url::parse(CONSTELLATION_URL)
234 .map_err(|e| weaver_common::WeaverError::InvalidNotebook(e.to_string()))?;
235
236 let existing_doc_count = match fetcher.client.xrpc(constellation_url).send(&query).await {
237 Ok(response) => match response.into_output() {
238 Ok(output) => output.total,
239 Err(_) => 0,
240 },
241 Err(_) => 0,
242 };
243
244 // If counts match, no backfill needed.
245 if existing_doc_count as usize == entry_list.len() {
246 tracing::info!(
247 "Document count ({}) matches entry count ({}), skipping backfill",
248 existing_doc_count,
249 entry_list.len()
250 );
251 return Ok(());
252 }
253
254 tracing::info!(
255 "Document count ({}) differs from entry count ({}), backfilling all entries",
256 existing_doc_count,
257 entry_list.len()
258 );
259
260 let mut created = 0;
261 let mut failed = 0;
262
263 // Upsert document for every entry in the entry_list (entries are indexed, no inline record needed).
264 for entry_ref in entry_list {
265 let entry_uri = &entry_ref.uri;
266
267 // Call generateDocument without inline record (will fetch from index).
268 let request = GenerateDocument::new()
269 .entry(entry_uri.clone())
270 .publication(publication_uri.clone())
271 .build();
272
273 let document = match fetcher.send(request).await {
274 Ok(response) => match response.into_output() {
275 Ok(output) => output.record,
276 Err(e) => {
277 tracing::warn!("generateDocument failed for {}: {}", entry_uri, e);
278 failed += 1;
279 continue;
280 }
281 },
282 Err(e) => {
283 tracing::warn!("Failed to call generateDocument for {}: {}", entry_uri, e);
284 failed += 1;
285 continue;
286 }
287 };
288
289 match fetcher.create_record(document, None).await {
290 Ok(_) => {
291 created += 1;
292 }
293 Err(e) => {
294 tracing::warn!("Failed to create document for {}: {}", entry_uri, e);
295 failed += 1;
296 }
297 }
298 }
299
300 tracing::info!(
301 "Backfill complete: {} documents created, {} failed",
302 created,
303 failed
304 );
305
306 Ok(())
307}