atproto blogging
1//! Sync notebook theme to ColourScheme and Theme records.
2
3use jacquard::client::AgentSessionExt;
4use jacquard::types::aturi::AtUri;
5use jacquard::types::string::{Cid, RecordKey};
6use jacquard::CowStr;
7use jacquard::IntoStatic;
8use weaver_api::com_atproto::repo::strong_ref::StrongRef;
9use weaver_api::sh_weaver::notebook::colour_scheme::ColourScheme;
10use weaver_api::sh_weaver::notebook::theme::{
11 Theme, ThemeDarkCodeTheme, ThemeFonts, ThemeLightCodeTheme, ThemeSpacing,
12};
13use weaver_common::WeaverError;
14use weaver_renderer::colour_gen::{
15 detect_variant, generate_counterpart_palette, generate_palette, ThemeVariant,
16};
17
18use crate::components::inline_theme_editor::InlineThemeValues;
19use crate::fetch::Fetcher;
20
21/// Result of syncing theme records.
22pub struct ThemeSyncResult {
23 pub theme_uri: AtUri<'static>,
24 pub theme_cid: Cid<'static>,
25}
26
27/// Create or update theme records for a notebook.
28///
29/// Returns the Theme record's URI and CID for updating Book.theme.
30pub async fn sync_theme(
31 fetcher: &Fetcher,
32 existing_theme_ref: Option<&StrongRef<'_>>,
33 values: &InlineThemeValues,
34) -> Result<ThemeSyncResult, WeaverError> {
35 // Determine primary variant.
36 let primary_variant = match values.default_mode.as_str() {
37 "light" => ThemeVariant::Light,
38 "dark" => ThemeVariant::Dark,
39 _ => detect_variant(&values.background)
40 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?,
41 };
42
43 // Generate palettes.
44 let light_inputs = values.light_inputs();
45 let dark_inputs = values.dark_inputs();
46
47 let (light_palette, dark_palette) = match primary_variant {
48 ThemeVariant::Light => {
49 let light = generate_palette(&light_inputs, ThemeVariant::Light)
50 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?;
51 let dark = if values.dark_background.is_some() {
52 generate_palette(&dark_inputs, ThemeVariant::Dark)
53 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?
54 } else {
55 generate_counterpart_palette(&light_inputs, ThemeVariant::Light)
56 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?
57 };
58 (light, dark)
59 }
60 ThemeVariant::Dark => {
61 let dark = generate_palette(&dark_inputs, ThemeVariant::Dark)
62 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?;
63 let light = if values.light_background.is_some() {
64 generate_palette(&light_inputs, ThemeVariant::Light)
65 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?
66 } else {
67 generate_counterpart_palette(&dark_inputs, ThemeVariant::Dark)
68 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?
69 };
70 (light, dark)
71 }
72 };
73
74 // Code theme values.
75 let light_code = ThemeLightCodeTheme::CodeThemeName(Box::new(CowStr::from(
76 values.light_code_theme.clone(),
77 )));
78 let dark_code =
79 ThemeDarkCodeTheme::CodeThemeName(Box::new(CowStr::from(values.dark_code_theme.clone())));
80 let default_theme: Option<CowStr<'static>> = match values.default_mode.as_str() {
81 "light" => Some(CowStr::from("light")),
82 "dark" => Some(CowStr::from("dark")),
83 _ => None,
84 };
85
86 if let Some(theme_ref) = existing_theme_ref {
87 // UPDATE existing records.
88 let theme_uri = &theme_ref.uri;
89 let existing_theme: Theme<'static> = fetcher
90 .fetch_record(&Theme::uri(theme_uri.as_ref())
91 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?)
92 .await
93 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?
94 .value
95 .into_static();
96
97 // Update light ColourScheme.
98 let light_scheme_rkey = existing_theme
99 .light_scheme
100 .uri
101 .rkey()
102 .ok_or_else(|| WeaverError::InvalidNotebook("Light scheme URI missing rkey".into()))?;
103 let light_result = fetcher
104 .put_record(
105 RecordKey::any(light_scheme_rkey.as_ref())
106 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?
107 .into_static(),
108 ColourScheme::new()
109 .name(CowStr::from("Custom Light"))
110 .variant(CowStr::from("light"))
111 .colours(light_palette)
112 .build(),
113 )
114 .await
115 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?;
116
117 // Update dark ColourScheme.
118 let dark_scheme_rkey = existing_theme
119 .dark_scheme
120 .uri
121 .rkey()
122 .ok_or_else(|| WeaverError::InvalidNotebook("Dark scheme URI missing rkey".into()))?;
123 let dark_result = fetcher
124 .put_record(
125 RecordKey::any(dark_scheme_rkey.as_ref())
126 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?
127 .into_static(),
128 ColourScheme::new()
129 .name(CowStr::from("Custom Dark"))
130 .variant(CowStr::from("dark"))
131 .colours(dark_palette)
132 .build(),
133 )
134 .await
135 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?;
136
137 // Update Theme with new CIDs.
138 let theme_rkey = theme_uri
139 .rkey()
140 .ok_or_else(|| WeaverError::InvalidNotebook("Theme URI missing rkey".into()))?;
141 let theme_result = fetcher
142 .put_record(
143 RecordKey::any(theme_rkey.as_ref())
144 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?
145 .into_static(),
146 Theme::new()
147 .light_scheme(
148 StrongRef::new()
149 .uri(light_result.uri.clone().into_static())
150 .cid(light_result.cid.clone().into_static())
151 .build(),
152 )
153 .dark_scheme(
154 StrongRef::new()
155 .uri(dark_result.uri.clone().into_static())
156 .cid(dark_result.cid.clone().into_static())
157 .build(),
158 )
159 .light_code_theme(light_code)
160 .dark_code_theme(dark_code)
161 .fonts(
162 ThemeFonts::new()
163 .body(vec![])
164 .heading(vec![])
165 .monospace(vec![])
166 .build(),
167 )
168 .spacing(ThemeSpacing {
169 base_size: CowStr::from("1rem"),
170 line_height: CowStr::from("1.6"),
171 scale: CowStr::from("1.25"),
172 extra_data: None,
173 })
174 .maybe_default_theme(default_theme)
175 .build(),
176 )
177 .await
178 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?;
179
180 Ok(ThemeSyncResult {
181 theme_uri: theme_result.uri.into_static(),
182 theme_cid: theme_result.cid.into_static(),
183 })
184 } else {
185 // CREATE new records with fresh TIDs.
186 let light_scheme = ColourScheme::new()
187 .name(CowStr::from("Custom Light"))
188 .variant(CowStr::from("light"))
189 .colours(light_palette)
190 .build();
191
192 let light_result = fetcher
193 .create_record(light_scheme, None)
194 .await
195 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?;
196
197 let dark_scheme = ColourScheme::new()
198 .name(CowStr::from("Custom Dark"))
199 .variant(CowStr::from("dark"))
200 .colours(dark_palette)
201 .build();
202
203 let dark_result = fetcher
204 .create_record(dark_scheme, None)
205 .await
206 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?;
207
208 let theme = Theme::new()
209 .light_scheme(
210 StrongRef::new()
211 .uri(light_result.uri.clone().into_static())
212 .cid(light_result.cid.clone().into_static())
213 .build(),
214 )
215 .dark_scheme(
216 StrongRef::new()
217 .uri(dark_result.uri.clone().into_static())
218 .cid(dark_result.cid.clone().into_static())
219 .build(),
220 )
221 .light_code_theme(light_code)
222 .dark_code_theme(dark_code)
223 .fonts(
224 ThemeFonts::new()
225 .body(vec![])
226 .heading(vec![])
227 .monospace(vec![])
228 .build(),
229 )
230 .spacing(ThemeSpacing {
231 base_size: CowStr::from("1rem"),
232 line_height: CowStr::from("1.6"),
233 scale: CowStr::from("1.25"),
234 extra_data: None,
235 })
236 .maybe_default_theme(default_theme)
237 .build();
238
239 let theme_result = fetcher
240 .create_record(theme, None)
241 .await
242 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?;
243
244 Ok(ThemeSyncResult {
245 theme_uri: theme_result.uri.into_static(),
246 theme_cid: theme_result.cid.into_static(),
247 })
248 }
249}
250
251/// Check if theme values have been customized from defaults.
252pub fn has_theme_customizations(values: &InlineThemeValues) -> bool {
253 !values.background.is_empty()
254 || !values.text.is_empty()
255 || !values.primary.is_empty()
256 || !values.link.is_empty()
257 || values.light_background.is_some()
258 || values.dark_background.is_some()
259}
260
261/// Load theme values from an existing theme reference.
262///
263/// Fetches Theme and ColourScheme records, extracts the 4 base colours
264/// (base→background, text→text, primary→primary, link→link).
265pub async fn load_theme_values(
266 fetcher: &Fetcher,
267 theme_ref: &StrongRef<'_>,
268) -> Result<InlineThemeValues, WeaverError> {
269 // Fetch Theme record.
270 let theme: Theme<'static> = fetcher
271 .fetch_record(
272 &Theme::uri(theme_ref.uri.as_ref())
273 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?,
274 )
275 .await
276 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?
277 .value
278 .into_static();
279
280 // Fetch light ColourScheme.
281 let light_scheme: ColourScheme<'static> = fetcher
282 .fetch_record(
283 &ColourScheme::uri(theme.light_scheme.uri.as_ref())
284 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?,
285 )
286 .await
287 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?
288 .value
289 .into_static();
290
291 // Fetch dark ColourScheme.
292 let dark_scheme: ColourScheme<'static> = fetcher
293 .fetch_record(
294 &ColourScheme::uri(theme.dark_scheme.uri.as_ref())
295 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?,
296 )
297 .await
298 .map_err(|e| WeaverError::InvalidNotebook(e.to_string().into()))?
299 .value
300 .into_static();
301
302 // Determine default mode.
303 let default_mode = theme
304 .default_theme
305 .as_ref()
306 .map(|s| s.to_string())
307 .unwrap_or_else(|| "auto".to_string());
308
309 // Extract code themes.
310 let light_code_theme = match &theme.light_code_theme {
311 ThemeLightCodeTheme::CodeThemeName(name) => name.as_ref().to_string(),
312 ThemeLightCodeTheme::CodeThemeFile(file) => file.name.as_ref().to_string(),
313 ThemeLightCodeTheme::Unknown(_) => "base16-ocean.light".to_string(),
314 };
315 let dark_code_theme = match &theme.dark_code_theme {
316 ThemeDarkCodeTheme::CodeThemeName(name) => name.as_ref().to_string(),
317 ThemeDarkCodeTheme::CodeThemeFile(file) => file.name.as_ref().to_string(),
318 ThemeDarkCodeTheme::Unknown(_) => "base16-ocean.dark".to_string(),
319 };
320
321 // Determine primary variant and extract base colours.
322 let (background, text, primary, link) = match default_mode.as_str() {
323 "dark" => (
324 dark_scheme.colours.base.to_string(),
325 dark_scheme.colours.text.to_string(),
326 dark_scheme.colours.primary.to_string(),
327 dark_scheme.colours.link.to_string(),
328 ),
329 _ => (
330 light_scheme.colours.base.to_string(),
331 light_scheme.colours.text.to_string(),
332 light_scheme.colours.primary.to_string(),
333 light_scheme.colours.link.to_string(),
334 ),
335 };
336
337 // Check if variants differ (user customized per-variant colours).
338 let light_differs = default_mode == "dark"
339 && (light_scheme.colours.base.as_ref() != dark_scheme.colours.base.as_ref()
340 || light_scheme.colours.text.as_ref() != dark_scheme.colours.text.as_ref()
341 || light_scheme.colours.primary.as_ref() != dark_scheme.colours.primary.as_ref()
342 || light_scheme.colours.link.as_ref() != dark_scheme.colours.link.as_ref());
343
344 let dark_differs = default_mode != "dark"
345 && (dark_scheme.colours.base.as_ref() != light_scheme.colours.base.as_ref()
346 || dark_scheme.colours.text.as_ref() != light_scheme.colours.text.as_ref()
347 || dark_scheme.colours.primary.as_ref() != light_scheme.colours.primary.as_ref()
348 || dark_scheme.colours.link.as_ref() != light_scheme.colours.link.as_ref());
349
350 Ok(InlineThemeValues {
351 background,
352 text,
353 primary,
354 link,
355 light_background: if light_differs {
356 Some(light_scheme.colours.base.to_string())
357 } else {
358 None
359 },
360 light_text: if light_differs {
361 Some(light_scheme.colours.text.to_string())
362 } else {
363 None
364 },
365 light_primary: if light_differs {
366 Some(light_scheme.colours.primary.to_string())
367 } else {
368 None
369 },
370 light_link: if light_differs {
371 Some(light_scheme.colours.link.to_string())
372 } else {
373 None
374 },
375 dark_background: if dark_differs {
376 Some(dark_scheme.colours.base.to_string())
377 } else {
378 None
379 },
380 dark_text: if dark_differs {
381 Some(dark_scheme.colours.text.to_string())
382 } else {
383 None
384 },
385 dark_primary: if dark_differs {
386 Some(dark_scheme.colours.primary.to_string())
387 } else {
388 None
389 },
390 dark_link: if dark_differs {
391 Some(dark_scheme.colours.link.to_string())
392 } else {
393 None
394 },
395 light_code_theme,
396 dark_code_theme,
397 default_mode,
398 })
399}