at main 307 lines 11 kB view raw
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>(&notebook_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 &notebook_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 &notebook_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, &notebook_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}