zeyn 0.0.6

Rust web framework for data heavy applications, written by AI agents and reviewed by people
Documentation
//! Endpoints of the component library: its assets, lazy node fragments and data sources.
//!
//! Every call goes through the same checks as grids and forms: CSRF, the screen's access, the
//! feature's guards, then the action's permission and the interceptors.

use super::{AppState, Call, CallKind, Failure, asset};
use crate::action::Error;
use crate::form::{Reading, read_moment};
use crate::i18n::Text;
use crate::render::Render;
use crate::time::{DateWords, range_presets};
use crate::ui::{NodePath, input_text, picker_config};
use crate::value::{FieldKind, Value};
use axum::Router;
use axum::extract::State;
use axum::http::HeaderMap;
use axum::response::{Html, IntoResponse, Response};
use axum::routing::{get, post};
use serde::Deserialize;
use serde_json::Value as Json;
use serde_json::json;
use std::sync::Arc;

const JS: &str = include_str!("../../assets/components.js");
const CSS: &str = include_str!("../../assets/components.css");
const CATALOG_JS: &str = include_str!("../../assets/catalog.js");
const CATALOG_CSS: &str = include_str!("../../assets/catalog.css");
const CONTENT_CSS: &str = include_str!("../../assets/content.css");

/// Asset paths served without authentication, like the runtime itself.
pub(super) const ASSETS: [&str; 5] = [
    "/_zeyn/components.js",
    "/_zeyn/components.css",
    "/_zeyn/catalog.js",
    "/_zeyn/catalog.css",
    "/_zeyn/content.css",
];

/// Links placed in the page head after the runtime's own. The catalog script (card grids, rails,
/// search boxes) is not among them: components.js loads it when a page first holds one.
pub(super) fn head(base: &str) -> String {
    let v = super::ops::asset_version();
    format!(
        r#"<link rel="stylesheet" href="{base}/_zeyn/components.css{v}"><script type="module" src="{base}/_zeyn/components.js{v}"></script><link rel="stylesheet" href="{base}/_zeyn/catalog.css{v}"><link rel="stylesheet" href="{base}/_zeyn/content.css{v}">"#
    )
}

pub(super) fn routes(router: Router<Arc<AppState>>) -> Router<Arc<AppState>> {
    router
        .route(
            "/_zeyn/components.js",
            get(|| async { asset(JS, "text/javascript; charset=utf-8") }),
        )
        .route(
            "/_zeyn/components.css",
            get(|| async { asset(CSS, "text/css; charset=utf-8") }),
        )
        .route(
            "/_zeyn/catalog.js",
            get(|| async { asset(CATALOG_JS, "text/javascript; charset=utf-8") }),
        )
        .route(
            "/_zeyn/catalog.css",
            get(|| async { asset(CATALOG_CSS, "text/css; charset=utf-8") }),
        )
        .route(
            "/_zeyn/content.css",
            get(|| async { asset(CONTENT_CSS, "text/css; charset=utf-8") }),
        )
        .route("/_zeyn/node", post(node_call))
        .route("/_zeyn/data", post(data_call))
        .route("/_zeyn/parse", post(parse_call))
}

#[derive(Deserialize)]
struct ParseCall {
    /// `date`, `time`, `datetime`, `local` or `presets`.
    kind: String,
    #[serde(default)]
    text: String,
    /// Field label for messages; shown as text only.
    #[serde(default)]
    label: String,
}

/// Reads what was typed into a date or time editor the way the form will, in the user's language,
/// zone and clock, and answers with the canonical text. The browser shows this and never computes
/// values itself. `presets` answers today and the quick ranges in the user's zone.
async fn parse_call(
    State(st): State<Arc<AppState>>,
    headers: HeaderMap,
    axum::Json(call): axum::Json<ParseCall>,
) -> Response {
    let ctx = st.licensed(st.ctx(&headers)).await;
    if let Err(f) = st.check_csrf(&headers) {
        return st.error_response(&ctx, f);
    }
    let words = DateWords::from_i18n(ctx.i18n(), ctx.locale());
    let reading = Reading::new(ctx.locale(), ctx.time_zone(), ctx.now(), &words);
    let r = Render::new("parse", ctx.locale(), ctx.i18n()).with_tz(ctx.time_zone());
    if call.kind == "presets" {
        let today = ctx.today();
        let presets: Vec<Json> = range_presets(today)
            .into_iter()
            .map(|(key, from, to)| {
                json!({ "key": key, "label": ctx.t(&Text::key(format!("zeyn.ui.date.presets.{key}"))), "from": from.to_string(), "to": to.to_string() })
            })
            .collect();
        let mut config = picker_config(&r);
        config["today"] = json!(today.to_string());
        config["presets"] = json!(presets);
        return axum::Json(config).into_response();
    }
    let kind = match call.kind.as_str() {
        "date" => FieldKind::Date,
        "time" => FieldKind::Time,
        "datetime" => FieldKind::DateTime,
        "local" => FieldKind::LocalDateTime,
        _ => return st.error_response(&ctx, Failure::App(Error::NotFound)),
    };
    let label: String = call.label.chars().take(200).collect();
    let text: String = call.text.chars().take(100).collect();
    if text.trim().is_empty() {
        return axum::Json(json!({ "ok": true, "text": "", "iso": "" })).into_response();
    }
    match read_moment(kind, &text, &reading) {
        Ok(value) => {
            let iso = match &value {
                Value::DateTime(d) => d.local(ctx.time_zone()).to_string(),
                other => serde_json::to_value(other)
                    .ok()
                    .and_then(|v| v.as_str().map(String::from))
                    .unwrap_or_default(),
            };
            let utc = match &value {
                Value::DateTime(d) => Some(d.to_string()),
                _ => None,
            };
            axum::Json(json!({ "ok": true, "text": input_text(&value, &r), "iso": iso, "utc": utc, "zone": ctx.time_zone().name() }))
                .into_response()
        }
        Err(skipped) => {
            let key = if skipped {
                "zeyn.validation.skipped_time"
            } else {
                "zeyn.ui.date.invalid"
            };
            axum::Json(
                json!({ "ok": false, "message": ctx.t(&Text::key(key).arg("label", label)) }),
            )
            .into_response()
        }
    }
}

#[derive(Deserialize)]
struct NodeCall {
    screen: String,
    #[serde(default)]
    params: Json,
    node: NodePath,
}

#[derive(Deserialize)]
struct DataCall {
    screen: String,
    #[serde(default)]
    params: Json,
    node: NodePath,
    #[serde(default)]
    key: String,
    #[serde(default)]
    input: Json,
}

/// Renders one node of a screen, with the first page of its grids. Used by lazy tabs. Runs
/// through the interceptors and records the read like opening the screen does.
async fn node_call(
    State(st): State<Arc<AppState>>,
    headers: HeaderMap,
    axum::Json(call): axum::Json<NodeCall>,
) -> Response {
    let ctx = st.licensed(st.ctx(&headers)).await;
    if let Err(f) = st.check_csrf(&headers) {
        return st.error_response(&ctx, f);
    }
    let Some((&name, _)) = st
        .screens
        .get_key_value(call.screen.as_str())
    else {
        return st.error_response(&ctx, Failure::App(Error::NotFound));
    };
    let info = Call {
        kind: CallKind::Screen,
        screen: name,
        action: None,
        via_agent: ctx.via_agent(),
    };
    let result = st
        .intercept(&ctx, &info, || async {
            let (_, entry, screen) = st
                .open(&ctx, name, call.params.clone())
                .await?;
            let node = screen
                .node(&call.node)
                .ok_or(Failure::App(Error::NotFound))?;
            super::data::note_screen_read(&ctx, &entry.meta, &call.params);
            let loaded = st
                .preload(&ctx, screen.walk_from(&call.node), entry.meta.name)
                .await;
            let r = super::user_render(entry.meta.name, &ctx);
            let r = Render {
                loaded: Some(&loaded),
                ..r
            };
            let mut out = String::new();
            crate::ui::render_node(&mut out, &r, call.node.as_str(), node);
            Ok::<_, Failure>(out)
        })
        .await;
    match result {
        Ok(html) => Html(html).into_response(),
        Err(f) => st.error_response(&ctx, f),
    }
}

/// Runs the action behind a component's data source and returns its JSON.
async fn data_call(
    State(st): State<Arc<AppState>>,
    headers: HeaderMap,
    axum::Json(call): axum::Json<DataCall>,
) -> Response {
    let ctx = st.licensed(st.ctx(&headers)).await;
    if let Err(f) = st.check_csrf(&headers) {
        return st.error_response(&ctx, f);
    }
    let result = async {
        let (_, entry, screen) = st
            .open(&ctx, &call.screen, call.params)
            .await?;
        let source = screen
            .node(&call.node)
            .and_then(|n| n.source(&call.key))
            .ok_or(Failure::App(Error::NotFound))?;
        let action = source.action();
        if !ctx.identity().can(&action.access) {
            return Err(Failure::App(Error::Forbidden));
        }
        let info = Call {
            kind: CallKind::DataGrid,
            screen: entry.meta.name,
            action: Some(action),
            via_agent: ctx.via_agent(),
        };
        st.intercept(&ctx, &info, || {
            st.timed(source.fetch(ctx.clone(), call.input))
        })
        .await
    }
    .await;
    match result {
        Ok(json) => axum::Json(json).into_response(),
        Err(f) => st.error_response(&ctx, f),
    }
}