//! Notebook settings panel - reusable editor with save/cancel logic. use crate::components::notebook::{ create_document_for_entry, delete_publication, document_exists, has_theme_customizations, load_theme_values, publication_uri_for_notebook, sync_publication, sync_theme, }; use crate::components::notebook_editor::{NotebookEditor, NotebookEditorMode, NotebookFormState}; use crate::fetch::Fetcher; use dioxus::prelude::*; use jacquard::types::aturi::AtUri; use jacquard::types::string::Datetime; use weaver_api::com_atproto::repo::strong_ref::StrongRef; use weaver_api::sh_weaver::notebook::book::Book; /// Notebook settings panel with state management and save logic. /// /// Renders the NotebookEditor form and handles save/cancel. /// Can be rendered inline or wrapped in a dialog by parent. #[component] pub fn NotebookSettingsPanel( notebook_uri: AtUri<'static>, book: Book<'static>, #[props(default)] on_saved: Option>, #[props(default)] on_cancel: Option>, ) -> Element { let fetcher = use_context::(); let mut saving = use_signal(|| false); let mut error = use_signal(|| None::); // Form state - initialized from book, theme loaded async. let mut form_state = use_signal(|| NotebookFormState::from_book(&book)); let mut theme_loaded = use_signal(|| false); // Load theme values on mount. let book_for_theme = book.clone(); let theme_fetcher = fetcher.clone(); use_effect(move || { if !theme_loaded() { let fetcher = theme_fetcher.clone(); let book = book_for_theme.clone(); spawn(async move { if let Some(theme_ref) = &book.theme { match load_theme_values(&fetcher, theme_ref).await { Ok(theme_values) => { form_state.write().theme = theme_values; } Err(e) => { tracing::warn!("Failed to load theme: {:?}", e); } } } theme_loaded.set(true); }); } }); // Save handler. let notebook_uri_for_save = notebook_uri.clone(); let book_for_save = book.clone(); let save_fetcher = fetcher.clone(); let on_saved_handler = on_saved.clone(); let handle_save = move |new_state: NotebookFormState| { let fetcher = save_fetcher.clone(); let notebook_uri = notebook_uri_for_save.clone(); let existing_book = book_for_save.clone(); let on_saved = on_saved_handler.clone(); spawn(async move { use jacquard::CowStr; use jacquard::client::AgentSessionExt; saving.set(true); error.set(None); let now = Datetime::now(); let tags: Option>> = if new_state.tags.is_empty() { None } else { Some( new_state .tags .iter() .map(|s| CowStr::from(s.clone())) .collect(), ) }; let path: CowStr<'static> = new_state.path.clone().into(); let title: CowStr<'static> = new_state.title.clone().into(); let publish_global = new_state.publish_global; // Sync theme if there are customizations. let theme_ref: Option> = if has_theme_customizations(&new_state.theme) { match sync_theme(&fetcher, existing_book.theme.as_ref(), &new_state.theme).await { Ok(result) => Some( StrongRef::new() .uri(result.theme_uri) .cid(result.theme_cid) .build(), ), Err(e) => { error.set(Some(format!("Failed to sync theme: {:?}", e))); saving.set(false); return; } } } else { existing_book.theme.clone() }; let client = fetcher.get_client(); match client .update_record::(¬ebook_uri, |book| { book.title = Some(title.clone()); if !path.is_empty() { book.path = Some(path.clone()); } book.publish_global = Some(publish_global); book.tags = tags.clone(); book.updated_at = Some(now.clone()); book.theme = theme_ref.clone(); }) .await { Ok(_output) => { // Sync or delete publication based on publish_global. if publish_global { if let Err(e) = sync_publication( &fetcher, ¬ebook_uri, &title, &path, &new_state.theme, ) .await { tracing::warn!("Failed to sync publication: {:?}", e); } // Backfill documents if publishGlobal was just enabled. let was_global = existing_book.publish_global.unwrap_or(false); if !was_global { if let Err(e) = backfill_documents( &fetcher, ¬ebook_uri, &existing_book.entry_list, ) .await { tracing::warn!("Failed to backfill documents: {:?}", e); } } } else if let Err(e) = delete_publication(&fetcher, ¬ebook_uri).await { tracing::warn!("Failed to delete publication: {:?}", e); } saving.set(false); if let Some(handler) = &on_saved { handler.call(()); } } Err(e) => { error.set(Some(format!("Failed to save settings: {:?}", e))); saving.set(false); } } }); }; let on_cancel_handler = on_cancel.clone(); let handle_cancel = move |_| { error.set(None); if let Some(handler) = &on_cancel_handler { handler.call(()); } }; rsx! { NotebookEditor { mode: NotebookEditorMode::Edit, initial_state: Some(form_state()), on_save: handle_save, on_cancel: handle_cancel, saving: saving(), error: error(), } } } /// Backfill site.standard.document records for all entries in a notebook. /// /// Called when publishGlobal is toggled on for an existing notebook. /// Uses the book's entry_list and constellation backlinks to determine if backfill is needed. /// Entries are already indexed, so we just pass URIs to generateDocument (no inline records). async fn backfill_documents( fetcher: &Fetcher, notebook_uri: &AtUri<'_>, entry_list: &[weaver_api::com_atproto::repo::strong_ref::StrongRef<'_>], ) -> Result<(), weaver_common::WeaverError> { use jacquard::prelude::*; use jacquard::types::uri::Uri; use weaver_api::sh_weaver::domain::generate_document::GenerateDocument; use weaver_common::constellation::GetBacklinksQuery; const CONSTELLATION_URL: &str = "https://constellation.microcosm.blue"; if entry_list.is_empty() { tracing::info!("No entries to backfill"); return Ok(()); } let publication_uri = publication_uri_for_notebook(notebook_uri).ok_or_else(|| { weaver_common::WeaverError::InvalidNotebook("Could not build publication URI".into()) })?; // Query constellation for existing documents that link to this publication. let subject = Uri::new(publication_uri.as_str()) .map_err(|e| weaver_common::WeaverError::InvalidNotebook(e.to_string()))?; let query = GetBacklinksQuery { subject, source: "site.standard.document:site".into(), cursor: None, did: vec![], limit: 1000, }; let constellation_url = jacquard::url::Url::parse(CONSTELLATION_URL) .map_err(|e| weaver_common::WeaverError::InvalidNotebook(e.to_string()))?; let existing_doc_count = match fetcher.client.xrpc(constellation_url).send(&query).await { Ok(response) => match response.into_output() { Ok(output) => output.total, Err(_) => 0, }, Err(_) => 0, }; // If counts match, no backfill needed. if existing_doc_count as usize == entry_list.len() { tracing::info!( "Document count ({}) matches entry count ({}), skipping backfill", existing_doc_count, entry_list.len() ); return Ok(()); } tracing::info!( "Document count ({}) differs from entry count ({}), backfilling all entries", existing_doc_count, entry_list.len() ); let mut created = 0; let mut failed = 0; // Upsert document for every entry in the entry_list (entries are indexed, no inline record needed). for entry_ref in entry_list { let entry_uri = &entry_ref.uri; // Call generateDocument without inline record (will fetch from index). let request = GenerateDocument::new() .entry(entry_uri.clone()) .publication(publication_uri.clone()) .build(); let document = match fetcher.send(request).await { Ok(response) => match response.into_output() { Ok(output) => output.record, Err(e) => { tracing::warn!("generateDocument failed for {}: {}", entry_uri, e); failed += 1; continue; } }, Err(e) => { tracing::warn!("Failed to call generateDocument for {}: {}", entry_uri, e); failed += 1; continue; } }; match fetcher.create_record(document, None).await { Ok(_) => { created += 1; } Err(e) => { tracing::warn!("Failed to create document for {}: {}", entry_uri, e); failed += 1; } } } tracing::info!( "Backfill complete: {} documents created, {} failed", created, failed ); Ok(()) }