~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
256 lines · 8.8 KB Raw History
//! 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"
        );
    }
}