johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! 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<String> {
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<ThemeView> {
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<String>,
q: Option<String>,
limit: Option<u32>,
cursor: Option<String>,
}
#[derive(Serialize)]
pub struct ThemesOutput {
themes: Vec<ThemeView>,
#[serde(skip_serializing_if = "Option::is_none")]
cursor: Option<String>,
}
/// `dev.knotless.listThemes`.
pub async fn list_themes(
State(app): State<Arc<App>>,
Query(p): Query<ThemesParams>,
Query(pairs): Query<Vec<(String, String)>>,
) -> 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<Response> {
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<Response> {
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<Arc<App>>,
Extension(viewer): Extension<Viewer>,
headers: HeaderMap,
axum::Json(input): axum::Json<CreateTheme>,
) -> Result<Response> {
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<Arc<App>>,
Extension(viewer): Extension<Viewer>,
headers: HeaderMap,
axum::Json(input): axum::Json<DeleteTheme>,
) -> Result<Response> {
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"
);
}
}