atproto blogging
1//! Sync notebook to site.standard.publication.
2
3use jacquard::CowStr;
4use jacquard::IntoStatic;
5use jacquard::client::AgentSessionExt;
6use jacquard::types::aturi::AtUri;
7use jacquard::types::string::{RecordKey, Uri};
8use weaver_api::site_standard::publication::Publication;
9use weaver_api::site_standard::theme::basic::Basic as BasicTheme;
10use weaver_api::site_standard::theme::color::Rgb;
11use weaver_common::WeaverError;
12
13use crate::components::inline_theme_editor::InlineThemeValues;
14use crate::fetch::Fetcher;
15
16/// Create or update a site.standard.publication for a notebook.
17pub async fn sync_publication(
18 fetcher: &Fetcher,
19 notebook_uri: &AtUri<'_>,
20 title: &str,
21 path: &str,
22 theme: &InlineThemeValues,
23) -> Result<AtUri<'static>, WeaverError> {
24 // Build basicTheme from inline values.
25 let basic_theme = build_basic_theme(theme);
26
27 // Build publication record.
28 let url_str = format!("https://weaver.sh/{}/{}", notebook_uri.authority(), path);
29 let url = Uri::new(&url_str)
30 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?
31 .into_static();
32 let publication = Publication::new()
33 .name(CowStr::from(title))
34 .url(url)
35 .maybe_basic_theme(Some(basic_theme))
36 .build();
37
38 // Use same rkey as notebook for 1:1 mapping.
39 let notebook_rkey = notebook_uri
40 .rkey()
41 .ok_or_else(|| WeaverError::InvalidNotebook("Notebook URI missing rkey".into()))?;
42
43 // Use put_record for upsert behavior.
44 let response = fetcher
45 .put_record(
46 RecordKey::any(notebook_rkey.as_ref())
47 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?
48 .into_static(),
49 publication,
50 )
51 .await
52 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?;
53
54 Ok(response.uri.into_static())
55}
56
57/// Delete a site.standard.publication.
58pub async fn delete_publication(
59 fetcher: &Fetcher,
60 notebook_uri: &AtUri<'_>,
61) -> Result<(), WeaverError> {
62 let notebook_rkey = notebook_uri
63 .rkey()
64 .ok_or_else(|| WeaverError::InvalidNotebook("Notebook URI missing rkey".into()))?;
65
66 // Use delete_record - ignore errors if doesn't exist.
67 let _ = fetcher
68 .delete_record::<Publication>(
69 RecordKey::any(notebook_rkey.as_ref())
70 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?
71 .into_static(),
72 )
73 .await;
74
75 Ok(())
76}
77
78/// Build a BasicTheme from InlineThemeValues.
79fn build_basic_theme(values: &InlineThemeValues) -> BasicTheme<'static> {
80 // Parse hex colours to RGB values.
81 let (bg_r, bg_g, bg_b) = parse_hex_to_rgb(&values.background);
82 let (fg_r, fg_g, fg_b) = parse_hex_to_rgb(&values.text);
83 let (ac_r, ac_g, ac_b) = parse_hex_to_rgb(&values.primary);
84
85 // Calculate accentForeground for contrast.
86 let (ac_fg_r, ac_fg_g, ac_fg_b) = calculate_contrast_rgb(ac_r, ac_g, ac_b);
87
88 BasicTheme::new()
89 .background(
90 Rgb::new()
91 .r(bg_r as i64)
92 .g(bg_g as i64)
93 .b(bg_b as i64)
94 .build(),
95 )
96 .foreground(
97 Rgb::new()
98 .r(fg_r as i64)
99 .g(fg_g as i64)
100 .b(fg_b as i64)
101 .build(),
102 )
103 .accent(
104 Rgb::new()
105 .r(ac_r as i64)
106 .g(ac_g as i64)
107 .b(ac_b as i64)
108 .build(),
109 )
110 .accent_foreground(
111 Rgb::new()
112 .r(ac_fg_r as i64)
113 .g(ac_fg_g as i64)
114 .b(ac_fg_b as i64)
115 .build(),
116 )
117 .build()
118}
119
120/// Parse a hex string (without #) to RGB tuple.
121fn parse_hex_to_rgb(hex: &str) -> (u8, u8, u8) {
122 // Strip leading # if present.
123 let hex = hex.trim_start_matches('#');
124
125 let r = u8::from_str_radix(&hex.get(0..2).unwrap_or("80"), 16).unwrap_or(128);
126 let g = u8::from_str_radix(&hex.get(2..4).unwrap_or("80"), 16).unwrap_or(128);
127 let b = u8::from_str_radix(&hex.get(4..6).unwrap_or("80"), 16).unwrap_or(128);
128
129 (r, g, b)
130}
131
132/// Calculate a contrasting colour (black or white) based on luminance.
133fn calculate_contrast_rgb(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
134 // Calculate relative luminance.
135 let luminance = (0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64) / 255.0;
136
137 // Return black or white for contrast.
138 if luminance > 0.5 {
139 (0, 0, 0) // Black.
140 } else {
141 (255, 255, 255) // White.
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn test_parse_hex_to_rgb() {
151 assert_eq!(parse_hex_to_rgb("FF0000"), (255, 0, 0));
152 assert_eq!(parse_hex_to_rgb("00FF00"), (0, 255, 0));
153 assert_eq!(parse_hex_to_rgb("0000FF"), (0, 0, 255));
154 assert_eq!(parse_hex_to_rgb("#FFFFFF"), (255, 255, 255));
155 assert_eq!(parse_hex_to_rgb("000000"), (0, 0, 0));
156 }
157
158 #[test]
159 fn test_calculate_contrast_rgb() {
160 // White background -> black text.
161 assert_eq!(calculate_contrast_rgb(255, 255, 255), (0, 0, 0));
162 // Black background -> white text.
163 assert_eq!(calculate_contrast_rgb(0, 0, 0), (255, 255, 255));
164 // Bright yellow -> black text.
165 assert_eq!(calculate_contrast_rgb(255, 255, 0), (0, 0, 0));
166 // Dark blue -> white text.
167 assert_eq!(calculate_contrast_rgb(0, 0, 128), (255, 255, 255));
168 }
169
170 #[test]
171 fn test_build_basic_theme() {
172 let values = InlineThemeValues {
173 background: "FFFFFF".to_string(),
174 text: "000000".to_string(),
175 primary: "0066CC".to_string(),
176 link: "0066CC".to_string(),
177 light_code_theme: "github".to_string(),
178 dark_code_theme: "github-dark".to_string(),
179 default_mode: "light".to_string(),
180 ..Default::default()
181 };
182
183 let theme = build_basic_theme(&values);
184
185 // Background should be white.
186 assert_eq!(theme.background.r, 255);
187 assert_eq!(theme.background.g, 255);
188 assert_eq!(theme.background.b, 255);
189
190 // Foreground should be black.
191 assert_eq!(theme.foreground.r, 0);
192 assert_eq!(theme.foreground.g, 0);
193 assert_eq!(theme.foreground.b, 0);
194
195 // Accent should be blue.
196 assert_eq!(theme.accent.r, 0);
197 assert_eq!(theme.accent.g, 102);
198 assert_eq!(theme.accent.b, 204);
199
200 // Accent foreground should be white (contrast against dark blue).
201 assert_eq!(theme.accent_foreground.r, 255);
202 assert_eq!(theme.accent_foreground.g, 255);
203 assert_eq!(theme.accent_foreground.b, 255);
204 }
205}