471 lines
15 KiB
Rust
471 lines
15 KiB
Rust
//! Server functions for the margin layer: notes and Claude-ask.
|
|
//!
|
|
//! Notes live as markdown files in `$MANTRA_NOTES_DIR/<slug>.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<Corpus>` in context.
|
|
|
|
#[server(endpoint = "source_page")]
|
|
pub async fn fetch_source_page(
|
|
slug: String,
|
|
lang: String,
|
|
) -> Result<Option<SourcePageData>, ServerFnError> {
|
|
#[cfg(feature = "ssr")]
|
|
{
|
|
use crate::corpus::{BilingualHandle, Lang};
|
|
let bilingual = use_context::<BilingualHandle>()
|
|
.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<LandingData, ServerFnError> {
|
|
#[cfg(feature = "ssr")]
|
|
{
|
|
use crate::corpus::{BilingualHandle, Lang};
|
|
let bilingual = use_context::<BilingualHandle>()
|
|
.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<Option<ThemePageData>, ServerFnError> {
|
|
#[cfg(feature = "ssr")]
|
|
{
|
|
use crate::corpus::{BilingualHandle, Lang};
|
|
let bilingual = use_context::<BilingualHandle>()
|
|
.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<Vec<NoteEntry>, 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<String, ServerFnError> {
|
|
#[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<NoteEntry> {
|
|
// Structure we parse:
|
|
// ## {#<para-id>}
|
|
// > excerpt
|
|
//
|
|
// ### Alexey · 2026-04-23 12:34
|
|
// Note body.
|
|
//
|
|
// ### Alexey · 2026-04-23 13:00 (asked)
|
|
// **Q:** ...
|
|
//
|
|
// **A (Claude):** ...
|
|
|
|
let mut entries: Vec<NoteEntry> = Vec::new();
|
|
let mut current_para: Option<String> = None;
|
|
let mut current_excerpt: String = String::new();
|
|
let mut current_author_ts: Option<String> = None;
|
|
let mut current_kind: NoteKind = NoteKind::Note;
|
|
let mut current_body: String = String::new();
|
|
|
|
fn flush(
|
|
entries: &mut Vec<NoteEntry>,
|
|
current_para: &Option<String>,
|
|
current_excerpt: &str,
|
|
current_author_ts: &Option<String>,
|
|
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<String, String> {
|
|
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('\'', "'\\''")
|
|
}
|