//! Notebook settings view with full theme editor. use dioxus::prelude::*; use jacquard::client::AgentSessionExt; use jacquard::common::from_data; use jacquard::smol_str::SmolStr; use jacquard::types::aturi::AtUri; use jacquard::types::ident::AtIdentifier; use jacquard::types::string::Datetime; use jacquard::{CowStr, IntoStatic}; use weaver_api::com_atproto::repo::strong_ref::StrongRef; use weaver_api::sh_weaver::notebook::book::Book; use weaver_api::sh_weaver::notebook::colour_scheme::ColourScheme; use weaver_api::sh_weaver::notebook::theme::{ Theme, ThemeDarkCodeTheme, ThemeFonts, ThemeLightCodeTheme, ThemeSpacing, }; use crate::Route; use crate::auth::AuthState; use crate::components::button::{Button, ButtonVariant}; use crate::components::notebook::{delete_publication, sync_publication}; use crate::components::{ColourSchemeValues, ThemeEditor, ThemeEditorValues}; use crate::data; use crate::fetch::Fetcher; const NOTEBOOK_SETTINGS_CSS: Asset = asset!("/assets/styling/notebook-settings.css"); /// Form state for notebook settings. #[derive(Debug, Clone, PartialEq, Default)] pub struct NotebookSettingsState { pub title: String, pub path: String, pub publish_global: bool, pub tags: Vec, pub tags_input: String, pub content_warnings: Vec, pub rating: Option, pub theme: ThemeEditorValues, } /// Props for NotebookSettings view. #[derive(Props, Clone, PartialEq)] pub struct NotebookSettingsProps { pub ident: ReadSignal>, pub book_title: ReadSignal, } /// Notebook settings page with full theme editor. #[component] pub fn NotebookSettings(props: NotebookSettingsProps) -> Element { // Load notebook data. let (notebook_result, notebook_data) = data::use_notebook(props.ident, props.book_title); #[cfg(feature = "fullstack-server")] let _ = notebook_result?; let auth_state = use_context::>(); let fetcher = use_context::(); let navigator = use_navigator(); // Form state - editable copy of initial values. let mut state = use_signal(NotebookSettingsState::default); let mut state_initialized = use_signal(|| false); let mut theme_values = use_signal(|| ThemeEditorValues::from_preset("rose-pine").unwrap_or_default()); let mut theme_initialized = use_signal(|| false); let mut saving = use_signal(|| false); let mut error = use_signal(|| None::); // Active section for navigation. let mut active_section = use_signal(|| "general".to_string()); // Check ownership. let current_did = auth_state.read().did.clone(); let ident_val = props.ident.read().clone(); let is_owner = match (¤t_did, &ident_val) { (Some(did), AtIdentifier::Did(ident_did)) => *did == *ident_did, _ => false, }; // Derive notebook URI and book from loaded data. let notebook_uri = use_memo(move || { let data = notebook_data()?; let (notebook_view, _) = &data; Some(notebook_view.uri.clone().into_static()) }); let current_book = use_memo(move || { let data = notebook_data()?; let (notebook_view, _) = &data; let book: Book<'_> = from_data(¬ebook_view.record).ok()?; Some(book.into_static()) }); // Derive initial form state from notebook data. let initial_form_state = use_memo(move || { let book = current_book()?; Some(NotebookSettingsState { title: book .title .as_ref() .map(|t| t.as_ref().to_string()) .unwrap_or_default(), path: book .path .as_ref() .map(|p| p.as_ref().to_string()) .unwrap_or_default(), publish_global: book.publish_global.unwrap_or(false), tags: book .tags .as_ref() .map(|t| t.iter().map(|s| s.as_ref().to_string()).collect()) .unwrap_or_default(), tags_input: String::new(), content_warnings: book .content_warnings .as_ref() .map(|cw| cw.iter().map(|s| s.as_ref().to_string()).collect()) .unwrap_or_default(), rating: book.rating.as_ref().map(|r| r.as_ref().to_string()), theme: ThemeEditorValues::default(), }) }); // Load theme values from theme ref. let theme_fetcher = fetcher.clone(); let theme_resource = use_resource(move || { let theme_ref = current_book() .and_then(|b| b.theme.clone()) .map(|t| t.into_static()); let fetcher = theme_fetcher.clone(); async move { let Some(theme_ref) = theme_ref else { return None; }; load_full_theme_values(&fetcher, &theme_ref).await.ok() } }); // Initialize editable state from loaded data (once). use_effect(move || { if !state_initialized() { if let Some(form_state) = initial_form_state() { state.set(form_state); state_initialized.set(true); } } if !theme_initialized() { if let Some(Some(theme_vals)) = theme_resource.read().as_ref() { theme_values.set(theme_vals.clone()); theme_initialized.set(true); } } }); if !is_owner { return rsx! { div { class: "notebook-settings-unauthorized", h1 { "Unauthorized" } p { "You don't have permission to edit this notebook's settings." } } }; } // Save general settings handler. let save_fetcher = fetcher.clone(); let handle_save = move |_| { let fetcher = save_fetcher.clone(); let uri = notebook_uri(); let book = current_book(); spawn(async move { let Some(uri) = uri else { error.set(Some("Notebook not loaded".to_string())); return; }; let Some(existing_book) = book else { error.set(Some("Notebook not loaded".to_string())); return; }; saving.set(true); error.set(None); let form = state(); let now = Datetime::now(); let tags: Option>> = if form.tags.is_empty() { None } else { Some(form.tags.iter().map(|s| CowStr::from(s.clone())).collect()) }; use weaver_api::sh_weaver::notebook::{ContentRating, ContentWarning}; let content_warnings: Option>> = if form.content_warnings.is_empty() { None } else { Some( form.content_warnings .iter() .map(|s| ContentWarning::from(s.clone())) .collect(), ) }; let path: CowStr<'static> = form.path.clone().into(); let title: CowStr<'static> = form.title.clone().into(); let publish_global = form.publish_global; let rating: Option> = form.rating.clone().map(|r| ContentRating::from(r)); let client = fetcher.get_client(); match client .update_record::(&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.content_warnings = content_warnings.clone(); book.rating = rating.clone(); book.updated_at = Some(now.clone()); }) .await { Ok(_) => { // Sync or delete publication based on publish_global. let theme_vals = crate::components::InlineThemeValues::default(); if publish_global { if let Err(e) = sync_publication(&fetcher, &uri, &title, &path, &theme_vals).await { tracing::warn!("Failed to sync publication: {:?}", e); } } else if let Err(e) = delete_publication(&fetcher, &uri).await { tracing::warn!("Failed to delete publication: {:?}", e); } saving.set(false); } Err(e) => { error.set(Some(format!("Failed to save: {:?}", e))); saving.set(false); } } }); }; rsx! { document::Stylesheet { href: NOTEBOOK_SETTINGS_CSS } div { class: "notebook-settings", // Sidebar navigation. nav { class: "notebook-settings-nav", button { class: if active_section() == "general" { "active" } else { "" }, onclick: move |_| active_section.set("general".to_string()), "General" } button { class: if active_section() == "theme" { "active" } else { "" }, onclick: move |_| active_section.set("theme".to_string()), "Theme" } button { class: if active_section() == "collaborators" { "active" } else { "" }, onclick: move |_| active_section.set("collaborators".to_string()), "Collaborators" } button { class: if active_section() == "danger" { "active" } else { "" }, onclick: move |_| active_section.set("danger".to_string()), "Danger Zone" } } // Content area. div { class: "notebook-settings-content", match active_section().as_str() { "general" => rsx! { GeneralSection { state: state, saving: saving(), error: error(), on_save: handle_save, } }, "theme" => rsx! { ThemeSection { values: theme_values, saving: saving(), on_save: { let fetcher = fetcher.clone(); move |values: ThemeEditorValues| { let fetcher = fetcher.clone(); let uri = notebook_uri(); let book = current_book(); let values = values.clone(); spawn(async move { let Some(uri) = uri else { error.set(Some("Notebook not loaded".to_string())); return; }; let Some(existing_book) = book else { error.set(Some("Notebook not loaded".to_string())); return; }; saving.set(true); error.set(None); // Sync theme records. match sync_full_theme(&fetcher, existing_book.theme.as_ref(), &values).await { Ok(theme_result) => { // Update book with new theme ref. let theme_ref = StrongRef::new() .uri(theme_result.theme_uri) .cid(theme_result.theme_cid) .build(); let client = fetcher.get_client(); let now = Datetime::now(); match client .update_record::(&uri, |book| { book.theme = Some(theme_ref.clone()); book.updated_at = Some(now.clone()); }) .await { Ok(_) => { theme_values.set(values); saving.set(false); } Err(e) => { error.set(Some(format!("Failed to update book: {:?}", e))); saving.set(false); } } } Err(e) => { error.set(Some(format!("Failed to sync theme: {:?}", e))); saving.set(false); } } }); } }, } }, "collaborators" => rsx! { CollaboratorsSection {} }, "danger" => rsx! { DangerSection { notebook_uri: notebook_uri(), on_deleted: { let ident = ident_val.clone(); move |_| { navigator.push(Route::RepositoryIndex { ident: ident.clone() }); } }, } }, _ => rsx! { div { "Unknown section" } }, } } } } } /// General settings section. #[component] fn GeneralSection( state: Signal, saving: bool, error: Option, on_save: EventHandler<()>, ) -> Element { let mut state = state; rsx! { div { class: "notebook-settings-section", h2 { "General Settings" } // Title field. div { class: "notebook-settings-field", label { "Title" } input { r#type: "text", value: "{state.read().title}", placeholder: "My Notebook", oninput: move |e| state.write().title = e.value(), } } // Path field. div { class: "notebook-settings-field", label { "Path" } input { r#type: "text", value: "{state.read().path}", placeholder: "my-notebook", oninput: move |e| state.write().path = e.value(), } span { class: "notebook-settings-hint", "URL-friendly identifier for your notebook." } } // Publish globally toggle. div { class: "notebook-settings-field notebook-settings-toggle", label { input { r#type: "checkbox", checked: state.read().publish_global, onchange: move |e| state.write().publish_global = e.checked(), } " Publish globally" } span { class: "notebook-settings-hint", "Enable cross-platform discovery via site.standard.* records." } } // Tags field. div { class: "notebook-settings-field", label { "Tags" } div { class: "notebook-settings-tags", for (i, tag) in state.read().tags.iter().enumerate() { span { key: "{i}", class: "notebook-settings-tag", "{tag}" button { class: "notebook-settings-tag-remove", onclick: move |_| { state.write().tags.remove(i); }, "×" } } } input { r#type: "text", class: "notebook-settings-tags-input", value: "{state.read().tags_input}", placeholder: "Add tag...", oninput: move |e| state.write().tags_input = e.value(), onkeydown: move |e| { if e.key() == Key::Enter || e.key() == Key::Character(",".to_string()) { e.prevent_default(); let tag = state.read().tags_input.trim().to_string(); if !tag.is_empty() { let mut s = state.write(); if !s.tags.contains(&tag) { s.tags.push(tag); } s.tags_input.clear(); } } }, } } } // Content rating. div { class: "notebook-settings-field", label { "Content Rating" } select { value: state.read().rating.clone().unwrap_or_default(), onchange: move |e: Event| { let val = e.value(); state.write().rating = if val.is_empty() { None } else { Some(val) }; }, option { value: "", "None" } option { value: "general", "General" } option { value: "mature", "Mature" } option { value: "adult", "Adult" } } } // Error display. if let Some(ref err) = error { div { class: "notebook-settings-error", "{err}" } } // Save button. div { class: "notebook-settings-actions", Button { variant: ButtonVariant::Primary, onclick: move |_| on_save.call(()), disabled: saving, if saving { "Saving..." } else { "Save Changes" } } } } } } /// Theme settings section with full editor. #[component] fn ThemeSection( values: Signal, saving: bool, on_save: EventHandler, ) -> Element { rsx! { div { class: "notebook-settings-section notebook-settings-theme", h2 { "Theme Settings" } p { class: "notebook-settings-description", "Customize the appearance of your notebook with colours, fonts, and spacing." } ThemeEditor { values: values, on_save: on_save, on_cancel: move |_| {}, saving: saving, } } } } /// Collaborators section. #[component] fn CollaboratorsSection() -> Element { rsx! { div { class: "notebook-settings-section", h2 { "Collaborators" } p { class: "notebook-settings-description", "Manage who can edit this notebook." } // TODO: Integrate CollaboratorsPanel when notebook URI is available. div { class: "notebook-settings-placeholder", "Collaborator management coming soon." } } } } /// Danger zone section. #[component] fn DangerSection(notebook_uri: Option>, on_deleted: EventHandler<()>) -> Element { let fetcher = use_context::(); let mut show_delete_confirm = use_signal(|| false); let mut deleting = use_signal(|| false); let mut delete_error = use_signal(|| None::); let delete_fetcher = fetcher.clone(); let notebook_uri_for_delete = notebook_uri.clone(); let handle_delete = move |_| { let Some(uri) = notebook_uri_for_delete.clone() else { delete_error.set(Some("Notebook not loaded".to_string())); return; }; let fetcher = delete_fetcher.clone(); spawn(async move { deleting.set(true); delete_error.set(None); // Delete all entries first, then the book. let rkey = match uri.rkey() { Some(r) => match RecordKey::any(r.as_ref()) { Ok(k) => k.into_static(), Err(_) => { delete_error.set(Some("Invalid record key".to_string())); deleting.set(false); return; } }, None => { delete_error.set(Some("Invalid notebook URI".to_string())); deleting.set(false); return; } }; let client = fetcher.get_client(); match client.delete_record::(rkey).await { Ok(_) => { deleting.set(false); show_delete_confirm.set(false); on_deleted.call(()); } Err(e) => { delete_error.set(Some(format!("Failed to delete: {:?}", e))); deleting.set(false); } } }); }; rsx! { div { class: "notebook-settings-section notebook-settings-danger", h2 { "Danger Zone" } if let Some(ref err) = delete_error() { div { class: "notebook-settings-error", "{err}" } } div { class: "notebook-settings-danger-item", div { class: "notebook-settings-danger-info", h3 { "Delete Notebook" } p { "Permanently delete this notebook and all its entries. This action cannot be undone." } } Button { variant: ButtonVariant::Destructive, onclick: move |_| show_delete_confirm.set(true), disabled: notebook_uri.is_none(), "Delete Notebook" } } if show_delete_confirm() { div { class: "notebook-settings-confirm-overlay", div { class: "notebook-settings-confirm-dialog", h3 { "Are you sure?" } p { "This will permanently delete the notebook and all its entries." } div { class: "notebook-settings-confirm-actions", Button { variant: ButtonVariant::Destructive, onclick: handle_delete, disabled: deleting(), if deleting() { "Deleting..." } else { "Yes, Delete" } } Button { variant: ButtonVariant::Ghost, onclick: move |_| show_delete_confirm.set(false), disabled: deleting(), "Cancel" } } } } } } } } // --- Helper functions for full theme sync --- use jacquard::types::string::{Cid, RecordKey}; use weaver_api::sh_weaver::notebook::colour_scheme::ColourSchemeColours; use weaver_common::WeaverError; /// Result of syncing theme records. pub struct FullThemeSyncResult { pub theme_uri: AtUri<'static>, pub theme_cid: Cid<'static>, } /// Load full theme values from existing theme records. async fn load_full_theme_values( fetcher: &Fetcher, theme_ref: &StrongRef<'_>, ) -> Result { // Fetch Theme record. let theme: Theme<'static> = fetcher .fetch_record( &Theme::uri(theme_ref.uri.as_ref()) .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?, ) .await .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? .value .into_static(); // Fetch light ColourScheme. let light_scheme: ColourScheme<'static> = fetcher .fetch_record( &ColourScheme::uri(theme.light_scheme.uri.as_ref()) .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?, ) .await .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? .value .into_static(); // Fetch dark ColourScheme. let dark_scheme: ColourScheme<'static> = fetcher .fetch_record( &ColourScheme::uri(theme.dark_scheme.uri.as_ref()) .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?, ) .await .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? .value .into_static(); // Extract code themes. let light_code_theme = match &theme.light_code_theme { ThemeLightCodeTheme::CodeThemeName(name) => name.as_ref().to_string(), ThemeLightCodeTheme::CodeThemeFile(file) => file.name.as_ref().to_string(), ThemeLightCodeTheme::Unknown(_) => "base16-ocean.light".to_string(), }; let dark_code_theme = match &theme.dark_code_theme { ThemeDarkCodeTheme::CodeThemeName(name) => name.as_ref().to_string(), ThemeDarkCodeTheme::CodeThemeFile(file) => file.name.as_ref().to_string(), ThemeDarkCodeTheme::Unknown(_) => "base16-ocean.dark".to_string(), }; let default_mode = theme .default_theme .as_ref() .map(|s| s.to_string()) .unwrap_or_else(|| "auto".to_string()); fn colours_to_scheme(colours: &ColourSchemeColours<'_>) -> ColourSchemeValues { ColourSchemeValues { base: strip_hex(&colours.base), surface: strip_hex(&colours.surface), overlay: strip_hex(&colours.overlay), text: strip_hex(&colours.text), muted: strip_hex(&colours.muted), subtle: strip_hex(&colours.subtle), emphasis: strip_hex(&colours.emphasis), primary: strip_hex(&colours.primary), secondary: strip_hex(&colours.secondary), tertiary: strip_hex(&colours.tertiary), error: strip_hex(&colours.error), warning: strip_hex(&colours.warning), success: strip_hex(&colours.success), border: strip_hex(&colours.border), link: strip_hex(&colours.link), highlight: strip_hex(&colours.highlight), } } Ok(ThemeEditorValues { light: colours_to_scheme(&light_scheme.colours), dark: colours_to_scheme(&dark_scheme.colours), font_body: String::new(), font_heading: String::new(), font_mono: String::new(), spacing_base: theme.spacing.base_size.to_string(), spacing_line_height: theme.spacing.line_height.to_string(), spacing_scale: theme.spacing.scale.to_string(), light_code_theme, dark_code_theme, default_mode, }) } fn strip_hex(s: &str) -> String { s.trim_start_matches('#') .trim_start_matches("0x") .trim_start_matches("0X") .to_uppercase() } /// Sync full theme values to ColourScheme and Theme records. async fn sync_full_theme( fetcher: &Fetcher, existing_theme_ref: Option<&StrongRef<'_>>, values: &ThemeEditorValues, ) -> Result { fn scheme_to_colours(scheme: &ColourSchemeValues) -> ColourSchemeColours<'static> { ColourSchemeColours { base: format!("#{}", scheme.base).into(), surface: format!("#{}", scheme.surface).into(), overlay: format!("#{}", scheme.overlay).into(), text: format!("#{}", scheme.text).into(), muted: format!("#{}", scheme.muted).into(), subtle: format!("#{}", scheme.subtle).into(), emphasis: format!("#{}", scheme.emphasis).into(), primary: format!("#{}", scheme.primary).into(), secondary: format!("#{}", scheme.secondary).into(), tertiary: format!("#{}", scheme.tertiary).into(), error: format!("#{}", scheme.error).into(), warning: format!("#{}", scheme.warning).into(), success: format!("#{}", scheme.success).into(), border: format!("#{}", scheme.border).into(), link: format!("#{}", scheme.link).into(), highlight: format!("#{}", scheme.highlight).into(), extra_data: None, } } let light_colours = scheme_to_colours(&values.light); let dark_colours = scheme_to_colours(&values.dark); let light_code = ThemeLightCodeTheme::CodeThemeName(Box::new(CowStr::from(values.light_code_theme.clone()))); let dark_code = ThemeDarkCodeTheme::CodeThemeName(Box::new(CowStr::from(values.dark_code_theme.clone()))); let default_theme: Option> = match values.default_mode.as_str() { "light" => Some(CowStr::from("light")), "dark" => Some(CowStr::from("dark")), _ => None, }; if let Some(theme_ref) = existing_theme_ref { // UPDATE existing records. let theme_uri = &theme_ref.uri; let existing_theme: Theme<'static> = fetcher .fetch_record( &Theme::uri(theme_uri.as_ref()) .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?, ) .await .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? .value .into_static(); // Update light ColourScheme. let light_scheme_rkey = existing_theme.light_scheme.uri.rkey().ok_or_else(|| { WeaverError::InvalidNotebook("Light scheme URI missing rkey".into()) })?; let light_result = fetcher .put_record( RecordKey::any(light_scheme_rkey.as_ref()) .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? .into_static(), ColourScheme::new() .name(CowStr::from("Custom Light")) .variant(CowStr::from("light")) .colours(light_colours) .build(), ) .await .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; // Update dark ColourScheme. let dark_scheme_rkey = existing_theme.dark_scheme.uri.rkey().ok_or_else(|| { WeaverError::InvalidNotebook("Dark scheme URI missing rkey".into()) })?; let dark_result = fetcher .put_record( RecordKey::any(dark_scheme_rkey.as_ref()) .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? .into_static(), ColourScheme::new() .name(CowStr::from("Custom Dark")) .variant(CowStr::from("dark")) .colours(dark_colours) .build(), ) .await .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; // Update Theme with new CIDs. let theme_rkey = theme_uri .rkey() .ok_or_else(|| WeaverError::InvalidNotebook("Theme URI missing rkey".into()))?; let theme_result = fetcher .put_record( RecordKey::any(theme_rkey.as_ref()) .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? .into_static(), Theme::new() .light_scheme( StrongRef::new() .uri(light_result.uri.clone().into_static()) .cid(light_result.cid.clone().into_static()) .build(), ) .dark_scheme( StrongRef::new() .uri(dark_result.uri.clone().into_static()) .cid(dark_result.cid.clone().into_static()) .build(), ) .light_code_theme(light_code) .dark_code_theme(dark_code) .fonts( ThemeFonts::new() .body(vec![]) .heading(vec![]) .monospace(vec![]) .build(), ) .spacing(ThemeSpacing { base_size: CowStr::from(values.spacing_base.clone()), line_height: CowStr::from(values.spacing_line_height.clone()), scale: CowStr::from(values.spacing_scale.clone()), extra_data: None, }) .maybe_default_theme(default_theme) .build(), ) .await .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; Ok(FullThemeSyncResult { theme_uri: theme_result.uri.into_static(), theme_cid: theme_result.cid.into_static(), }) } else { // CREATE new records. let light_scheme = ColourScheme::new() .name(CowStr::from("Custom Light")) .variant(CowStr::from("light")) .colours(light_colours) .build(); let light_result = fetcher .create_record(light_scheme, None) .await .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; let dark_scheme = ColourScheme::new() .name(CowStr::from("Custom Dark")) .variant(CowStr::from("dark")) .colours(dark_colours) .build(); let dark_result = fetcher .create_record(dark_scheme, None) .await .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; let theme = Theme::new() .light_scheme( StrongRef::new() .uri(light_result.uri.clone().into_static()) .cid(light_result.cid.clone().into_static()) .build(), ) .dark_scheme( StrongRef::new() .uri(dark_result.uri.clone().into_static()) .cid(dark_result.cid.clone().into_static()) .build(), ) .light_code_theme(light_code) .dark_code_theme(dark_code) .fonts( ThemeFonts::new() .body(vec![]) .heading(vec![]) .monospace(vec![]) .build(), ) .spacing(ThemeSpacing { base_size: CowStr::from(values.spacing_base.clone()), line_height: CowStr::from(values.spacing_line_height.clone()), scale: CowStr::from(values.spacing_scale.clone()), extra_data: None, }) .maybe_default_theme(default_theme) .build(); let theme_result = fetcher .create_record(theme, None) .await .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; Ok(FullThemeSyncResult { theme_uri: theme_result.uri.into_static(), theme_cid: theme_result.cid.into_static(), }) } }