//! Sync notebook to site.standard.publication. use jacquard::CowStr; use jacquard::IntoStatic; use jacquard::client::AgentSessionExt; use jacquard::types::aturi::AtUri; use jacquard::types::string::{RecordKey, Uri}; use weaver_api::site_standard::publication::Publication; use weaver_api::site_standard::theme::basic::Basic as BasicTheme; use weaver_api::site_standard::theme::color::Rgb; use weaver_common::WeaverError; use crate::components::inline_theme_editor::InlineThemeValues; use crate::fetch::Fetcher; /// Create or update a site.standard.publication for a notebook. pub async fn sync_publication( fetcher: &Fetcher, notebook_uri: &AtUri<'_>, title: &str, path: &str, theme: &InlineThemeValues, ) -> Result, WeaverError> { // Build basicTheme from inline values. let basic_theme = build_basic_theme(theme); // Build publication record. let url_str = format!("https://weaver.sh/{}/{}", notebook_uri.authority(), path); let url = Uri::new(&url_str) .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? .into_static(); let publication = Publication::new() .name(CowStr::from(title)) .url(url) .maybe_basic_theme(Some(basic_theme)) .build(); // Use same rkey as notebook for 1:1 mapping. let notebook_rkey = notebook_uri .rkey() .ok_or_else(|| WeaverError::InvalidNotebook("Notebook URI missing rkey".into()))?; // Use put_record for upsert behavior. let response = fetcher .put_record( RecordKey::any(notebook_rkey.as_ref()) .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? .into_static(), publication, ) .await .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?; Ok(response.uri.into_static()) } /// Delete a site.standard.publication. pub async fn delete_publication( fetcher: &Fetcher, notebook_uri: &AtUri<'_>, ) -> Result<(), WeaverError> { let notebook_rkey = notebook_uri .rkey() .ok_or_else(|| WeaverError::InvalidNotebook("Notebook URI missing rkey".into()))?; // Use delete_record - ignore errors if doesn't exist. let _ = fetcher .delete_record::( RecordKey::any(notebook_rkey.as_ref()) .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))? .into_static(), ) .await; Ok(()) } /// Build a BasicTheme from InlineThemeValues. fn build_basic_theme(values: &InlineThemeValues) -> BasicTheme<'static> { // Parse hex colours to RGB values. let (bg_r, bg_g, bg_b) = parse_hex_to_rgb(&values.background); let (fg_r, fg_g, fg_b) = parse_hex_to_rgb(&values.text); let (ac_r, ac_g, ac_b) = parse_hex_to_rgb(&values.primary); // Calculate accentForeground for contrast. let (ac_fg_r, ac_fg_g, ac_fg_b) = calculate_contrast_rgb(ac_r, ac_g, ac_b); BasicTheme::new() .background( Rgb::new() .r(bg_r as i64) .g(bg_g as i64) .b(bg_b as i64) .build(), ) .foreground( Rgb::new() .r(fg_r as i64) .g(fg_g as i64) .b(fg_b as i64) .build(), ) .accent( Rgb::new() .r(ac_r as i64) .g(ac_g as i64) .b(ac_b as i64) .build(), ) .accent_foreground( Rgb::new() .r(ac_fg_r as i64) .g(ac_fg_g as i64) .b(ac_fg_b as i64) .build(), ) .build() } /// Parse a hex string (without #) to RGB tuple. fn parse_hex_to_rgb(hex: &str) -> (u8, u8, u8) { // Strip leading # if present. let hex = hex.trim_start_matches('#'); let r = u8::from_str_radix(&hex.get(0..2).unwrap_or("80"), 16).unwrap_or(128); let g = u8::from_str_radix(&hex.get(2..4).unwrap_or("80"), 16).unwrap_or(128); let b = u8::from_str_radix(&hex.get(4..6).unwrap_or("80"), 16).unwrap_or(128); (r, g, b) } /// Calculate a contrasting colour (black or white) based on luminance. fn calculate_contrast_rgb(r: u8, g: u8, b: u8) -> (u8, u8, u8) { // Calculate relative luminance. let luminance = (0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64) / 255.0; // Return black or white for contrast. if luminance > 0.5 { (0, 0, 0) // Black. } else { (255, 255, 255) // White. } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_hex_to_rgb() { assert_eq!(parse_hex_to_rgb("FF0000"), (255, 0, 0)); assert_eq!(parse_hex_to_rgb("00FF00"), (0, 255, 0)); assert_eq!(parse_hex_to_rgb("0000FF"), (0, 0, 255)); assert_eq!(parse_hex_to_rgb("#FFFFFF"), (255, 255, 255)); assert_eq!(parse_hex_to_rgb("000000"), (0, 0, 0)); } #[test] fn test_calculate_contrast_rgb() { // White background -> black text. assert_eq!(calculate_contrast_rgb(255, 255, 255), (0, 0, 0)); // Black background -> white text. assert_eq!(calculate_contrast_rgb(0, 0, 0), (255, 255, 255)); // Bright yellow -> black text. assert_eq!(calculate_contrast_rgb(255, 255, 0), (0, 0, 0)); // Dark blue -> white text. assert_eq!(calculate_contrast_rgb(0, 0, 128), (255, 255, 255)); } #[test] fn test_build_basic_theme() { let values = InlineThemeValues { background: "FFFFFF".to_string(), text: "000000".to_string(), primary: "0066CC".to_string(), link: "0066CC".to_string(), light_code_theme: "github".to_string(), dark_code_theme: "github-dark".to_string(), default_mode: "light".to_string(), ..Default::default() }; let theme = build_basic_theme(&values); // Background should be white. assert_eq!(theme.background.r, 255); assert_eq!(theme.background.g, 255); assert_eq!(theme.background.b, 255); // Foreground should be black. assert_eq!(theme.foreground.r, 0); assert_eq!(theme.foreground.g, 0); assert_eq!(theme.foreground.b, 0); // Accent should be blue. assert_eq!(theme.accent.r, 0); assert_eq!(theme.accent.g, 102); assert_eq!(theme.accent.b, 204); // Accent foreground should be white (contrast against dark blue). assert_eq!(theme.accent_foreground.r, 255); assert_eq!(theme.accent_foreground.g, 255); assert_eq!(theme.accent_foreground.b, 255); } }