//! Themes for the pages, as `dev.knotless.theme` records hold them: //! `listThemes`, from the index, and `createTheme` and `deleteTheme`, as //! the account logged in to the pages; and a palette as the cookie the //! pages draw from holds it. A palette is held to its lexicon, and to //! contrast, by knotless's own `theme`, as the command line's is. use super::stars::logged_in; use super::*; use crate::viewer::Viewer; use axum::Extension; use knotless::keys; use serde_json::{Value, json}; use knotless::theme::{COLOURS, NAME, SYNTAX}; pub use knotless::theme::{palette, written}; /// `palette`, as [`palette`] keeps it, as the cookie the pages draw it /// from holds it: its scheme, then each colour as `name-rrggbb`, between /// dots (web/src/lib/theme.ts reads it). pub fn cookie(palette: &Value) -> String { let mut said = vec![ palette["colorScheme"] .as_str() .unwrap_or_default() .to_string(), ]; let given = |at: &Value, names: &[(&str, &str)]| -> Vec { let each = names.iter().filter_map(|(name, _)| { let c = at[*name].as_str()?; Some(format!("{name}-{}", c.trim_start_matches('#'))) }); each.collect() }; said.extend(given(palette, &COLOURS)); said.extend(given(&palette["syntax"], &SYNTAX)); said.join(".") } /// `dev.knotless.defs#themeView`. #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct ThemeView { uri: String, creator: ActorView, name: String, palette: Value, created_at: String, } /// `record`, `did`'s theme at `uri`, as it is shown, if it is one. async fn view(app: &App, uri: String, did: &str, record: &Value) -> Option { let name = record["name"].as_str().filter(|n| !n.trim().is_empty())?; let palette = palette(&record["palette"])?; Some(ThemeView { uri, creator: actor(app, did).await, name: name.chars().take(NAME).collect(), palette, created_at: record["createdAt"].as_str().unwrap_or_default().into(), }) } #[derive(Deserialize)] pub struct ThemesParams { actor: Option, q: Option, limit: Option, cursor: Option, } #[derive(Serialize)] pub struct ThemesOutput { themes: Vec, #[serde(skip_serializing_if = "Option::is_none")] cursor: Option, } /// `dev.knotless.listThemes`. pub async fn list_themes( State(app): State>, Query(p): Query, Query(pairs): Query>, ) -> Response { let Some(index) = app.index.clone() else { return Shown(StatusCode::NOT_FOUND, "this server keeps no index".into()).into_response(); }; // By URI, as an account's added themes name them, `uris` repeating. let uris: Vec<&str> = pairs .iter() .filter(|(k, _)| k == "uris") .map(|(_, v)| v.as_str()) .collect(); if !uris.is_empty() { return named(&app, &index, &uris).await.into_response(); } let did = match &p.actor { Some(actor) => match knotless::identity::resolve(&app.ctx, actor).await { Ok(id) => Some(id.did), Err(e) => return no_profile(e), }, None => None, }; themes(&app, &index, did.as_deref(), p) .await .into_response() } async fn themes(app: &App, index: &Index, did: Option<&str>, p: ThemesParams) -> Result { let limit = p.limit.unwrap_or(50).clamp(1, 100); // By name, a page alone; else the latest first, a page at a time. let page = match p.q.as_deref().filter(|q| !q.trim().is_empty()) { Some(q) => index.themes_named(q, limit).map(|found| (found, None)), None => index.themes(did, limit, p.cursor.as_deref()), }; let (found, cursor) = page .map_err(anyhow::Error::from) .map_err(AppError::internal)?; let each = found .iter() .map(|(uri, did, record)| view(app, uri.clone(), did, record)); let themes = futures::future::join_all(each).await; Ok(answer(ThemesOutput { themes: themes.into_iter().flatten().collect(), cursor, })) } /// The most themes `listThemes` answers by URI, as many as an account adds. const URIS: usize = 50; /// The themes `uris` names, in its order: those the index has. async fn named(app: &App, index: &Index, uris: &[&str]) -> Result { if uris.len() > URIS { let why = format!("uris names {URIS} themes at most"); return Err(Shown(StatusCode::BAD_REQUEST, why)); } let found = index.themes_at(uris); let found = found .map_err(anyhow::Error::from) .map_err(AppError::internal)?; let each = found .iter() .map(|(uri, did, record)| view(app, uri.clone(), did, record)); let themes = futures::future::join_all(each).await; Ok(answer(ThemesOutput { themes: themes.into_iter().flatten().collect(), cursor: None, })) } #[derive(Deserialize)] pub struct CreateTheme { name: String, palette: Value, } /// The account logged in to the pages' own records, to write in. async fn mine(app: &App, did: &str) -> Result<(Remote, knotless::session::Session)> { let ctx = crate::login::ctx_for(app, did); let me = session::me(&ctx).await.map_err(AppError::upstream)?; let url = RepoUrl::parse(&format!("knotless://{did}/_")).map_err(AppError::internal)?; let remote = Remote::connect(&ctx, &url).await; Ok((remote.map_err(AppError::upstream)?, me)) } /// `dev.knotless.createTheme`: writes the theme, and indexes it at once, /// so the pages list it as soon as it is made. pub async fn create_theme( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { let did = logged_in(&app, viewer, &headers, "publish a theme")?; let name = input.name.trim(); if name.is_empty() || name.chars().count() > NAME { let why = format!("a theme's name is 1 to {NAME} characters"); return Err(Shown(StatusCode::BAD_REQUEST, why)); } // Every text in it read on what it is drawn on, as the pages check it. let palette = knotless::theme::checked(&input.palette); let palette = palette.map_err(|why| Shown(StatusCode::BAD_REQUEST, why.to_string()))?; let record = json!({ "$type": nsid::THEME, "name": name, "palette": palette, "createdAt": knotless::records::now(), }); let (remote, me) = mine(&app, &did).await?; let rkey = keys::tid(); let write = knotless::remote::create(nsid::THEME, &rkey, record.clone()); let wrote = remote.apply(vec![write], &me.auth()).await; wrote.map_err(AppError::upstream)?; // Indexed now, where there is an index, to be listed at once. let index = app.index.as_ref(); let put = index.map(|i| i.put(&did, nsid::THEME, &rkey, &record)); let put = put.transpose().map_err(anyhow::Error::from); put.map_err(AppError::internal)?; let uri = format!("at://{did}/{}/{rkey}", nsid::THEME); let shown = view(&app, uri, &did, &record).await; Ok(answer(shown)) } #[derive(Deserialize)] pub struct DeleteTheme { uri: String, } /// `dev.knotless.deleteTheme`: deletes one of the account's own themes, /// and takes it out of the index at once. pub async fn delete_theme( State(app): State>, Extension(viewer): Extension, headers: HeaderMap, axum::Json(input): axum::Json, ) -> Result { let did = logged_in(&app, viewer, &headers, "delete a theme")?; let prefix = format!("at://{did}/{}/", nsid::THEME); let key = |b: u8| b.is_ascii_alphanumeric() || b"._:~-".contains(&b); let rkey = input.uri.strip_prefix(&prefix); let rkey = rkey.filter(|k| !k.is_empty() && k.bytes().all(key)); let Some(rkey) = rkey else { let why = format!("{} is no theme of yours", input.uri); return Err(Shown(StatusCode::BAD_REQUEST, why)); }; let (remote, me) = mine(&app, &did).await?; let write = knotless::remote::delete(nsid::THEME, rkey); let wrote = remote.apply(vec![write], &me.auth()).await; wrote.map_err(AppError::upstream)?; let index = app.index.as_ref(); let deleted = index.map(|i| i.delete(&did, nsid::THEME, rkey)); let deleted = deleted.transpose().map_err(anyhow::Error::from); deleted.map_err(AppError::internal)?; Ok(answer(json!({}))) } #[cfg(test)] mod tests { use super::*; #[test] fn a_palette_goes_in_a_cookie() { let kept = json!({ "colorScheme": "dark", "background": "#11aa22", "text": "#eeeeee", "primary": "#123456", "accent": "#abcdef", "syntax": {"strings": "#00ff00"}, }); assert_eq!( cookie(&kept), "dark.background-11aa22.text-eeeeee.primary-123456.accent-abcdef.strings-00ff00" ); } }