//! Server functions for the margin layer: notes and Claude-ask. //! //! Notes live as markdown files in `$MANTRA_NOTES_DIR/.md`. //! Each file is append-oriented: a note is a dated entry anchored to //! a paragraph-id. Subsequent reads parse the file and return //! structured entries. pub mod types; use leptos::prelude::*; pub use types::{LandingData, NoteEntry, NoteKind, SourcePageData, ThemePageData}; // --- fetch_source_page ------------------------------------------------- // // Snapshot for `/source/:slug`. Moves corpus access into a server fn // so SSR and hydrate render from the same Resource payload and the // client doesn't need the full `Arc` in context. #[server(endpoint = "source_page")] pub async fn fetch_source_page( slug: String, lang: String, ) -> Result, ServerFnError> { #[cfg(feature = "ssr")] { use crate::corpus::{BilingualHandle, Lang}; let bilingual = use_context::() .ok_or_else(|| ServerFnError::new("no corpus in context"))?; let lang = Lang::from_query(Some(&lang)); let corpus = bilingual.for_lang(lang); let Some(src) = corpus.get(&slug).cloned() else { return Ok(None); }; let (prev, next) = corpus.neighbors(&slug); Ok(Some(SourcePageData { source: src, prev: prev.map(|s| s.to_string()), next: next.map(|s| s.to_string()), })) } #[cfg(not(feature = "ssr"))] { let _ = (slug, lang); unreachable!() } } // --- fetch_landing ----------------------------------------------------- #[server(endpoint = "landing")] pub async fn fetch_landing(lang: String) -> Result { #[cfg(feature = "ssr")] { use crate::corpus::{BilingualHandle, Lang}; let bilingual = use_context::() .ok_or_else(|| ServerFnError::new("no corpus in context"))?; let lang = Lang::from_query(Some(&lang)); let corpus = bilingual.for_lang(lang); let sources = corpus .order .iter() .filter_map(|slug| corpus.get(slug).cloned()) .collect(); Ok(LandingData { order: corpus.order.clone(), sources, themes: corpus.themes.clone(), }) } #[cfg(not(feature = "ssr"))] { let _ = lang; unreachable!() } } // --- fetch_theme_page -------------------------------------------------- #[server(endpoint = "theme_page")] pub async fn fetch_theme_page( slug: String, lang: String, ) -> Result, ServerFnError> { #[cfg(feature = "ssr")] { use crate::corpus::{BilingualHandle, Lang}; let bilingual = use_context::() .ok_or_else(|| ServerFnError::new("no corpus in context"))?; let lang = Lang::from_query(Some(&lang)); let corpus = bilingual.for_lang(lang); let Some(theme) = corpus.themes.iter().find(|t| t.slug == slug).cloned() else { return Ok(None); }; let contributing = theme .contributing .iter() .filter_map(|s| corpus.get(s).cloned()) .collect(); Ok(Some(ThemePageData { theme, contributing })) } #[cfg(not(feature = "ssr"))] { let _ = (slug, lang); unreachable!() } } // --- fetch_notes ------------------------------------------------------- #[server(endpoint = "notes_fetch")] pub async fn fetch_notes(source_slug: String) -> Result, ServerFnError> { #[cfg(feature = "ssr")] { let dir = std::env::var("MANTRA_NOTES_DIR").map_err(|_| { ServerFnError::new("MANTRA_NOTES_DIR not set on server") })?; let path = std::path::PathBuf::from(&dir).join(format!("{source_slug}.md")); if !path.exists() { return Ok(Vec::new()); } let raw = tokio::fs::read_to_string(&path) .await .map_err(|e| ServerFnError::new(format!("read notes: {e}")))?; Ok(parse_marginalia(&raw)) } #[cfg(not(feature = "ssr"))] { unreachable!() } } // --- save_note --------------------------------------------------------- #[server(endpoint = "notes_save")] pub async fn save_note( source_slug: String, para_id: String, para_excerpt: String, note_text: String, author: String, ) -> Result<(), ServerFnError> { #[cfg(feature = "ssr")] { if note_text.trim().is_empty() { return Err(ServerFnError::new("note text is empty")); } append_marginalia_entry( &source_slug, ¶_id, ¶_excerpt, MarginaliaPayload::Note(note_text), &author, ) .await?; spawn_git_autocommit(&source_slug, ¶_id).await; Ok(()) } #[cfg(not(feature = "ssr"))] { unreachable!() } } // --- ask_claude -------------------------------------------------------- #[server(endpoint = "claude_ask")] pub async fn ask_claude( source_slug: String, para_id: String, para_excerpt: String, question: String, author: String, ) -> Result { #[cfg(feature = "ssr")] { let q = question.trim(); if q.is_empty() { return Err(ServerFnError::new("question is empty")); } let api_key = std::env::var("ANTHROPIC_API_KEY").map_err(|_| { ServerFnError::new("ANTHROPIC_API_KEY not set on server") })?; let system = format!( "You are a reading companion for a philosophical substance called \ design-dna. A reader is studying the distillation of {source_slug} \ and has highlighted a passage. Answer their question grounded in the \ passage and its tradition. Be concise (≤300 words), direct, \ substance over summary. Same register as the distillation itself: \ living note, not academic report. Answer in the same language the \ user asked in. If you don't know, say so." ); let user_msg = format!( "Context passage:\n\n{para_excerpt}\n\n---\n\nQuestion: {q}" ); let answer = call_anthropic(&api_key, &system, &user_msg) .await .map_err(|e| ServerFnError::new(format!("claude: {e}")))?; // Persist Q+A to marginalia too — reading is a dialogue, and the // dialogue is the record. append_marginalia_entry( &source_slug, ¶_id, ¶_excerpt, MarginaliaPayload::Ask { question: q.to_string(), answer: answer.clone(), }, &author, ) .await?; spawn_git_autocommit(&source_slug, ¶_id).await; Ok(answer) } #[cfg(not(feature = "ssr"))] { unreachable!() } } // ===================================================================== // server-only implementations // ===================================================================== #[cfg(feature = "ssr")] enum MarginaliaPayload { Note(String), Ask { question: String, answer: String }, } #[cfg(feature = "ssr")] async fn append_marginalia_entry( source_slug: &str, para_id: &str, para_excerpt: &str, payload: MarginaliaPayload, author: &str, ) -> Result<(), ServerFnError> { let dir = std::env::var("MANTRA_NOTES_DIR").map_err(|_| { ServerFnError::new("MANTRA_NOTES_DIR not set on server") })?; let dir = std::path::PathBuf::from(&dir); tokio::fs::create_dir_all(&dir) .await .map_err(|e| ServerFnError::new(format!("mkdir: {e}")))?; let path = dir.join(format!("{source_slug}.md")); let existed = path.exists(); let header_needed = !existed; let ts = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC").to_string(); let mut out = String::new(); if header_needed { out.push_str(&format!( "---\ntype: marginalia\nsource: {source_slug}\nproject: design-dna\n---\n\n# Marginalia · {source_slug}\n\n" )); } // We always append a fresh block. Simple, no merge logic v0.1. out.push_str(&format!("## {{#{para_id}}}\n\n")); let excerpt = para_excerpt.trim(); if !excerpt.is_empty() { // Store the first 180 chars of excerpt as a blockquote anchor. let cut: String = excerpt.chars().take(180).collect(); out.push_str("> "); out.push_str(&cut.replace('\n', " ")); if excerpt.chars().count() > 180 { out.push_str(" …"); } out.push_str("\n\n"); } match payload { MarginaliaPayload::Note(text) => { out.push_str(&format!("### {author} · {ts}\n\n{text}\n\n")); } MarginaliaPayload::Ask { question, answer } => { out.push_str(&format!( "### {author} · {ts} (asked)\n\n**Q:** {question}\n\n**A (Claude):** {answer}\n\n" )); } } use tokio::io::AsyncWriteExt; let mut f = tokio::fs::OpenOptions::new() .create(true) .append(true) .open(&path) .await .map_err(|e| ServerFnError::new(format!("open notes: {e}")))?; f.write_all(out.as_bytes()) .await .map_err(|e| ServerFnError::new(format!("write notes: {e}")))?; Ok(()) } #[cfg(feature = "ssr")] fn parse_marginalia(raw: &str) -> Vec { // Structure we parse: // ## {#} // > excerpt // // ### Alexey · 2026-04-23 12:34 // Note body. // // ### Alexey · 2026-04-23 13:00 (asked) // **Q:** ... // // **A (Claude):** ... let mut entries: Vec = Vec::new(); let mut current_para: Option = None; let mut current_excerpt: String = String::new(); let mut current_author_ts: Option = None; let mut current_kind: NoteKind = NoteKind::Note; let mut current_body: String = String::new(); fn flush( entries: &mut Vec, current_para: &Option, current_excerpt: &str, current_author_ts: &Option, current_kind: NoteKind, current_body: &str, ) { if let (Some(para), Some(ats)) = (current_para.clone(), current_author_ts.clone()) { let body = current_body.trim(); if !body.is_empty() { entries.push(NoteEntry { para_id: para, excerpt: current_excerpt.trim().to_string(), author_ts: ats, kind: current_kind, body: body.to_string(), }); } } } for line in raw.lines() { if let Some(rest) = line.strip_prefix("## {#") { // Flush previous entry. flush( &mut entries, ¤t_para, ¤t_excerpt, ¤t_author_ts, current_kind, ¤t_body, ); current_body.clear(); current_author_ts = None; current_kind = NoteKind::Note; current_excerpt.clear(); let id = rest.trim_end_matches('}').trim(); current_para = Some(id.to_string()); } else if line.starts_with("> ") { if !current_excerpt.is_empty() { current_excerpt.push(' '); } current_excerpt.push_str(line.trim_start_matches("> ").trim()); } else if let Some(rest) = line.strip_prefix("### ") { flush( &mut entries, ¤t_para, ¤t_excerpt, ¤t_author_ts, current_kind, ¤t_body, ); current_body.clear(); let asked = rest.ends_with("(asked)"); current_kind = if asked { NoteKind::Ask } else { NoteKind::Note }; let head = rest.trim_end_matches("(asked)").trim(); current_author_ts = Some(head.to_string()); } else { current_body.push_str(line); current_body.push('\n'); } } flush( &mut entries, ¤t_para, ¤t_excerpt, ¤t_author_ts, current_kind, ¤t_body, ); entries } #[cfg(feature = "ssr")] async fn call_anthropic( api_key: &str, system: &str, user: &str, ) -> Result { let body = serde_json::json!({ "model": "claude-sonnet-4-6", "max_tokens": 1024, "system": system, "messages": [{ "role": "user", "content": user }], }); let resp = reqwest::Client::new() .post("https://api.anthropic.com/v1/messages") .header("x-api-key", api_key) .header("anthropic-version", "2023-06-01") .header("content-type", "application/json") .json(&body) .send() .await .map_err(|e| format!("request: {e}"))?; if !resp.status().is_success() { let st = resp.status(); let body = resp.text().await.unwrap_or_default(); return Err(format!("anthropic {st}: {body}")); } let v: serde_json::Value = resp.json().await.map_err(|e| format!("decode: {e}"))?; let text = v["content"][0]["text"] .as_str() .ok_or_else(|| "anthropic response missing content[0].text".to_string())?; Ok(text.to_string()) } #[cfg(feature = "ssr")] async fn spawn_git_autocommit(source_slug: &str, para_id: &str) { // Fire-and-forget: we don't want to block the user on git push. // Only runs if MANTRA_NOTES_GIT_AUTO_PUSH is set (opt-in). if std::env::var("MANTRA_NOTES_GIT_AUTO_PUSH").is_err() { return; } let dir = match std::env::var("MANTRA_NOTES_DIR") { Ok(d) => d, Err(_) => return, }; let slug = source_slug.to_string(); let pid = para_id.to_string(); tokio::spawn(async move { // Pull-rebase before push to avoid racing with other writers // (e.g. Mac's vault-push timer). Rebase auto-stashes local // changes so our pending commit survives. If rebase conflicts // somehow (shouldn't for append-only files), the push will // simply fail and we'll catch it on the next save. let script = format!( "set -e; cd {dir}; \ git add -A; \ git diff --cached --quiet && exit 0; \ git commit -m 'note: {slug} {pid}'; \ git pull --rebase --autostash origin main 2>&1 || true; \ git push origin main 2>&1", dir = shell_escape(&dir), slug = shell_escape(&slug), pid = shell_escape(&pid), ); let _ = tokio::process::Command::new("sh") .arg("-c") .arg(&script) .output() .await; }); } #[cfg(feature = "ssr")] fn shell_escape(s: &str) -> String { // Good enough for slugs (alphanumeric + dash) and ordinary paths. s.replace('\'', "'\\''") }