AkurAI Build
Menu

Public repository / 4fac5c7826068a0d615a09851633816b41204468

AkurAI-Notes / crates/notes-api/src/handlers/ssr.rs

Latest change d2e70c7ce91e59fd601d2d47a0e4ead5450385c8 - feat: add create_notebook, create_tag, list_tags, list_notes MCP tools; fix add_tag/remove_tag stubs; add notebookId/archived to update_note by Claude Code

use crate::router_setup::{internal_error, NotesState};
use akurai_http::{Request, Response};
use akurai_json::Value;
use akurai_template::Engine;
use std::fs;
use std::path::Path;
use std::sync::{Arc, Mutex};

pub type RouteHandler = Box<dyn Fn(&Request) -> Response + Send + Sync>;

fn negotiate_locale(req: &Request, known: &[String]) -> String {
    if let Some(cookie) = req.header("Cookie") {
        for pair in cookie.split(';') {
            let pair = pair.trim();
            if let Some(val) = pair
                .strip_prefix("akurai-lang=")
                .or_else(|| pair.strip_prefix("akurai_lang="))
            {
                let lang = val.trim().trim_matches('"').to_string();
                if known.iter().any(|l| l == &lang) {
                    return lang;
                }
            }
        }
    }
    if let Some(accept) = req.header("Accept-Language") {
        for part in accept.split(',') {
            let lang = part
                .split(';')
                .next()
                .unwrap_or("")
                .trim()
                .split('-')
                .next()
                .unwrap_or("")
                .trim();
            if !lang.is_empty() && known.iter().any(|l| l == lang) {
                return lang.to_string();
            }
        }
    }
    "en".into()
}

fn build_context(backend: &Path, req: &Request) -> Value {
    let mut pairs: Vec<(String, Value)> = Vec::new();
    pairs.push(("version".into(), Value::Str("v0.0.1".into())));

    let page_path = backend.join("page.json");
    if page_path.exists() {
        if let Ok(text) = fs::read_to_string(&page_path) {
            if let Ok(Value::Object(p)) = akurai_json::parse(&text) {
                for (k, v) in p {
                    pairs.push((k.clone(), v.clone()));
                }
            }
        }
    }

    let locales_dir = backend.join("locales");
    let mut known: Vec<String> = Vec::new();
    let mut catalogs: Vec<(String, Vec<(String, Value)>)> = Vec::new();

    if locales_dir.exists() {
        if let Ok(entries) = fs::read_dir(&locales_dir) {
            for entry in entries {
                let path = match entry {
                    Ok(e) => e.path(),
                    Err(_) => continue,
                };
                if path.extension().and_then(|e| e.to_str()) != Some("json") {
                    continue;
                }
                let locale = match path.file_stem().and_then(|s| s.to_str()) {
                    Some(s) => s.to_string(),
                    None => continue,
                };
                if let Ok(text) = fs::read_to_string(&path) {
                    if let Ok(Value::Object(msgs)) = akurai_json::parse(&text) {
                        known.push(locale.clone());
                        catalogs.push((locale, msgs));
                    }
                }
            }
        }
    }

    let locale = negotiate_locale(req, &known);
    pairs.push(("__locale".into(), Value::Str(locale.clone())));

    for (l, msgs) in &catalogs {
        if *l == locale {
            pairs.push(("__messages".into(), Value::Object(msgs.clone())));
            break;
        }
    }

    Value::Object(pairs)
}

fn build_engine(frontend: &Path) -> Result<Engine, String> {
    let mut engine = Engine::new();
    let entries = fs::read_dir(frontend).map_err(|e| format!("cannot read frontend: {e}"))?;
    for entry in entries {
        let path = match entry {
            Ok(e) => e.path(),
            Err(_) => continue,
        };
        if path.extension().and_then(|e| e.to_str()) != Some("html") {
            continue;
        }
        let stem = match path.file_stem().and_then(|s| s.to_str()) {
            Some(s) => s.to_string(),
            None => continue,
        };
        let source = match fs::read_to_string(&path) {
            Ok(s) => s,
            Err(_) => continue,
        };
        if let Err(e) = engine.register(&stem, &source) {
            eprintln!("ssr: failed to register {stem}.html: {}", e.message);
        }
    }
    Ok(engine)
}

pub fn render_template(
    frontend: &Path,
    backend: &Path,
    template: &str,
    layout: Option<&str>,
    req: &Request,
) -> Response {
    let engine = match build_engine(frontend) {
        Ok(e) => e,
        Err(msg) => return Response::new(500).with_text(&msg),
    };

    let context = build_context(backend, req);

    let page = match engine
        .render(template, &context)
        .or_else(|_| engine.render("index", &context))
    {
        Ok(html) => html,
        Err(e) => {
            let fallback = match fs::read_to_string(frontend.join("index.html")) {
                Ok(s) => s,
                Err(_) => format!("<h1>500</h1><p>template error: {}</p>", e.message),
            };
            return Response::new(500)
                .with_header("Content-Type", "text/html; charset=utf-8")
                .with_body("text/html", fallback.into_bytes());
        }
    };

    match layout {
        Some(layout_name) => {
            let mut ctx = context;
            if let Value::Object(pairs) = &mut ctx {
                pairs.push(("content".into(), Value::Str(page)));
            }
            match engine.render(layout_name, &ctx) {
                Ok(html) => Response::ok()
                    .with_header("Content-Type", "text/html; charset=utf-8")
                    .with_body("text/html; charset=utf-8", html.into_bytes()),
                Err(e) => {
                    let msg = format!(
                        "<h1>500</h1><p>layout error ({}): {}</p>",
                        layout_name, e.message
                    );
                    Response::new(500)
                        .with_header("Content-Type", "text/html; charset=utf-8")
                        .with_body("text/html", msg.into_bytes())
                }
            }
        }
        None => Response::ok()
            .with_header("Content-Type", "text/html; charset=utf-8")
            .with_body("text/html; charset=utf-8", page.into_bytes()),
    }
}

pub fn ssr_page_route(
    state: Arc<Mutex<NotesState>>,
    template: &'static str,
    layout: Option<&'static str>,
) -> RouteHandler {
    Box::new(move |req: &Request| -> Response {
        let s = match state.lock() {
            Ok(s) => s,
            Err(_) => return internal_error("lock poisoned"),
        };
        let frontend = &s.frontend_dir;
        let Some(parent) = frontend.parent() else {
            return Response::new(500).with_text("cannot find parent dir");
        };
        let backend = parent.join("backend");
        render_template(frontend, &backend, template, layout, req)
    })
}