use crate::{ error::ApiError, reader::{Material, bounded_text, get_material}, state::AppState, }; use axum::{ Json, Router, extract::{Path, State}, routing::{get, patch, post}, }; use chrono::{DateTime, Duration, Utc}; use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use uuid::Uuid; pub fn routes() -> Router { Router::new() .route("/api/reader/materials/{id}/learning", get(learning)) .route("/api/reader/materials/{id}/attempts", post(save_attempt)) .route("/api/reader/materials/{id}/notes", post(create_note)) .route( "/api/reader/notes/{id}", patch(update_note).delete(delete_note), ) .route("/api/reader/notes/{id}/capture", post(capture_note)) .route("/api/reader/materials/{id}/cards", post(create_card)) .route( "/api/reader/cards/{id}", patch(update_card).delete(delete_card), ) .route("/api/reader/cards/{id}/review", post(review_card)) .route("/api/reader/review", get(review)) } #[derive(Debug, Clone, Serialize, sqlx::FromRow)] pub(crate) struct ReaderNote { pub id: Uuid, pub material_id: Uuid, pub title: String, pub body: String, pub quote: String, pub page: Option, pub chat_id: Option, pub message_id: Option, pub citations: Value, pub captured_note_id: Option, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Clone, Serialize, sqlx::FromRow)] pub(crate) struct ReviewCard { pub id: Uuid, pub material_id: Uuid, pub question: String, pub answer: String, pub quote: String, pub page: Option, pub note_id: Option, pub due_at: DateTime, pub interval_days: f64, pub repetitions: i32, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct NoteInput { title: String, body: String, #[serde(default)] quote: String, page: Option, chat_id: Option, message_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct CardInput { question: String, answer: String, #[serde(default)] quote: String, page: Option, note_id: Option, } pub(crate) fn validate_location( material: &Material, quote: &str, page: Option, ) -> Result<(), ApiError> { bounded_text(quote, "quote", 12000, false)?; if page.is_some_and(|p| material.kind != "book" || p < 1 || p > material.page_count) { return Err(ApiError::BadRequest("invalid source page".into())); } Ok(()) } async fn learning( State(state): State, Path(id): Path, ) -> Result, ApiError> { get_material(&state, id).await?; let notes = sqlx::query_as::<_, ReaderNote>( "select * from reader_notes where material_id=$1 order by updated_at desc", ) .bind(id) .fetch_all(&state.platform_db) .await?; let cards = sqlx::query_as::<_, ReviewCard>( "select * from reader_review_cards where material_id=$1 order by due_at", ) .bind(id) .fetch_all(&state.platform_db) .await?; let attempts:Vec=sqlx::query_scalar("select to_jsonb(a) from reader_exercise_attempts a where material_id=$1 order by updated_at").bind(id).fetch_all(&state.platform_db).await?; Ok(Json( json!({"notes":notes,"cards":cards,"attempts":attempts}), )) } async fn save_attempt( State(state): State, Path(id): Path, Json(input): Json, ) -> Result { get_material(&state, id).await?; let key = input["exerciseKey"] .as_str() .ok_or_else(|| ApiError::BadRequest("exerciseKey is required".into()))?; let answer = input["answer"] .as_str() .ok_or_else(|| ApiError::BadRequest("answer is required".into()))?; bounded_text(key, "exerciseKey", 12000, true)?; bounded_text(answer, "answer", 20000, false)?; sqlx::query("insert into reader_exercise_attempts(material_id,exercise_key,answer) values($1,$2,$3) on conflict(material_id,exercise_key) do update set answer=excluded.answer,updated_at=now()") .bind(id).bind(key).bind(answer).execute(&state.platform_db).await?; Ok(axum::http::StatusCode::NO_CONTENT) } async fn save_note_record( state: &AppState, material: &Material, id: Uuid, input: NoteInput, previous: Option, ) -> Result { bounded_text(&input.title, "title", 200, true)?; bounded_text(&input.body, "body", 100000, true)?; validate_location(material, &input.quote, input.page)?; let mut citations = previous .as_ref() .map(|p| p.citations.clone()) .unwrap_or_else(|| json!([])); if let Some(chat_id) = input.chat_id { let exists:bool=sqlx::query_scalar("select exists(select 1 from agent_conversations where id=$1 and context_type='reader' and context_id=$2)").bind(chat_id).bind(material.id).fetch_one(&state.platform_db).await?; if !exists { return Err(ApiError::BadRequest( "conversation does not belong to this material".into(), )); } } if let Some(message_id) = input.message_id { let message:Option=sqlx::query_scalar("select to_jsonb(m) from agent_messages m join agent_conversations c on c.id=m.conversation_id where m.id=$1 and c.context_type='reader' and c.context_id=$2") .bind(message_id).bind(material.id).fetch_optional(&state.platform_db).await?; let message = message .ok_or_else(|| ApiError::BadRequest("choose a completed assistant answer".into()))?; if message["role"].as_str() != Some("assistant") || message["metadata"]["status"].as_str().unwrap_or("complete") != "complete" { return Err(ApiError::BadRequest( "choose a completed assistant answer".into(), )); } if input.chat_id.map(|id| id.to_string()) != message["conversation_id"].as_str().map(ToOwned::to_owned) { return Err(ApiError::BadRequest( "answer does not belong to this conversation".into(), )); } citations = message["metadata"] .get("citations") .cloned() .unwrap_or_else(|| json!([])); } let row = if previous.is_some() { sqlx::query_as::<_,ReaderNote>("update reader_notes set title=$2,body=$3,quote=$4,page=$5,updated_at=now() where id=$1 returning *") .bind(id).bind(input.title.trim()).bind(input.body.trim()).bind(input.quote).bind(input.page).fetch_one(&state.platform_db).await? } else { sqlx::query_as::<_,ReaderNote>("insert into reader_notes(id,material_id,title,body,quote,page,chat_id,message_id,citations) values($1,$2,$3,$4,$5,$6,$7,$8,$9) returning *") .bind(id).bind(material.id).bind(input.title.trim()).bind(input.body.trim()).bind(input.quote).bind(input.page).bind(input.chat_id).bind(input.message_id).bind(citations).fetch_one(&state.platform_db).await? }; Ok(row) } async fn create_note( State(state): State, Path(id): Path, Json(input): Json, ) -> Result<(axum::http::StatusCode, Json), ApiError> { let material = get_material(&state, id).await?; Ok(( axum::http::StatusCode::CREATED, Json(save_note_record(&state, &material, Uuid::now_v7(), input, None).await?), )) } async fn update_note( State(state): State, Path(id): Path, Json(input): Json, ) -> Result, ApiError> { let previous = sqlx::query_as::<_, ReaderNote>("select * from reader_notes where id=$1") .bind(id) .fetch_one(&state.platform_db) .await?; let material = get_material(&state, previous.material_id).await?; Ok(Json( save_note_record(&state, &material, id, input, Some(previous)).await?, )) } async fn delete_note( State(state): State, Path(id): Path, ) -> Result { sqlx::query("delete from reader_notes where id=$1") .bind(id) .execute(&state.platform_db) .await?; Ok(axum::http::StatusCode::NO_CONTENT) } async fn capture_note( State(state): State, Path(id): Path, ) -> Result, ApiError> { let mut tx = state.platform_db.begin().await?; let source = sqlx::query_as::<_, ReaderNote>("select * from reader_notes where id=$1 for update") .bind(id) .fetch_one(&mut *tx) .await?; if let Some(native) = source.captured_note_id { tx.commit().await?; return Ok(Json(json!({"id":native,"note_id":native}))); } let native = Uuid::now_v7(); let slug = format!("reader-note-{native}"); let document = json!({"schema_version":1,"content":markdown_document(&source.body,source.material_id),"origin":{"type":"reader_note","reader_note_id":source.id,"material_id":source.material_id,"quote":source.quote,"page":source.page,"chat_id":source.chat_id,"message_id":source.message_id,"citations":source.citations,"markdown_source":source.body}}); let excerpt: String = source.body.chars().take(240).collect(); sqlx::query( "insert into notes(id,slug,title,kind,excerpt,document) values($1,$2,$3,'card',$4,$5)", ) .bind(native) .bind(slug) .bind(&source.title) .bind(&excerpt) .bind(&document) .execute(&mut *tx) .await?; sqlx::query("insert into note_revisions(note_id,revision_number,title,excerpt,document,reason) values($1,1,$2,$3,$4,'captured from reader annotation')").bind(native).bind(&source.title).bind(excerpt).bind(document).execute(&mut *tx).await?; sqlx::query("update reader_notes set captured_note_id=$2 where id=$1") .bind(id) .bind(native) .execute(&mut *tx) .await?; tx.commit().await?; Ok(Json(json!({"id":native,"note_id":native}))) } struct MarkdownFrame { kind: String, attrs: Value, content: Vec, } impl MarkdownFrame { fn new(kind: &str, attrs: Value) -> Self { Self { kind: kind.into(), attrs, content: Vec::new(), } } fn finish(mut self) -> Value { if matches!( self.kind.as_str(), "listItem" | "taskItem" | "tableCell" | "tableHeader" ) && self.content.first().and_then(|node| node["type"].as_str()) != Some("paragraph") { self.content .insert(0, json!({"type":"paragraph","content":[]})); } let mut node = json!({"type":self.kind,"content":self.content}); if self.attrs.is_object() { node["attrs"] = self.attrs; } node } } fn append_inline(stack: &mut [MarkdownFrame], node: Value) { let frame = stack.last_mut().expect("document frame exists"); if matches!(frame.kind.as_str(), "paragraph" | "heading" | "codeBlock") { frame.content.push(node); } else { if frame.content.last().and_then(|n| n["type"].as_str()) != Some("paragraph") { frame.content.push(json!({"type":"paragraph","content":[]})); } frame.content.last_mut().unwrap()["content"] .as_array_mut() .unwrap() .push(node); } } fn append_block(stack: &mut [MarkdownFrame], node: Value) { let last = stack.len() - 1; if matches!(stack[last].kind.as_str(), "paragraph" | "heading") && last > 0 { if !stack[last].content.is_empty() { let content = std::mem::take(&mut stack[last].content); let mut paragraph = json!({"type":stack[last].kind,"content":content}); if stack[last].attrs.is_object() { paragraph["attrs"] = stack[last].attrs.clone(); } stack[last - 1].content.push(paragraph); } stack[last - 1].content.push(node); } else { stack[last].content.push(node); } } fn safe_markdown_url(url: &str, material: Uuid, image: bool) -> Option { if let Ok(parsed) = reqwest::Url::parse(url) { if matches!(parsed.scheme(), "http" | "https") || (!image && parsed.scheme() == "mailto") { return Some(url.to_owned()); } return None; } if url.starts_with('#') && !image { return Some(url.into()); } if crate::reader_pdf::safe_relative_path(url) { return Some(format!("/api/reader/files/{material}/{url}")); } if url.starts_with("/api/assets/") || url.starts_with("/api/reader/files/") { return Some(url.into()); } None } pub(crate) fn markdown_document(markdown: &str, material: Uuid) -> Value { let options = Options::ENABLE_MATH | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS | Options::ENABLE_TABLES; let parser = Parser::new_ext(markdown, options).into_offset_iter(); let mut stack = vec![MarkdownFrame::new("doc", Value::Null)]; let mut marks: Vec<(TagEnd, Option)> = Vec::new(); let mut image: Option<(String, String, String)> = None; let mut underline_depth = 0usize; let mut table_head = false; for (event, _range) in parser { match event { Event::Start(tag) => match tag { Tag::Paragraph => stack.push(MarkdownFrame::new("paragraph", Value::Null)), Tag::Heading { level, .. } => { stack.push(MarkdownFrame::new("heading", json!({"level":level as u8}))) } Tag::BlockQuote(_) => stack.push(MarkdownFrame::new("blockquote", Value::Null)), Tag::List(start) => stack.push(MarkdownFrame::new( if start.is_some() { "orderedList" } else { "bulletList" }, start.map(|n| json!({"start":n})).unwrap_or(Value::Null), )), Tag::Item => stack.push(MarkdownFrame::new("listItem", Value::Null)), Tag::CodeBlock(kind) => { let language = match kind { CodeBlockKind::Fenced(info) => { info.split_whitespace().next().unwrap_or("text").to_owned() } CodeBlockKind::Indented => "text".into(), }; stack.push(MarkdownFrame::new( "codeBlock", json!({"language":language}), )); } Tag::HtmlBlock => { stack.push(MarkdownFrame::new("codeBlock", json!({"language":"html"}))) } Tag::Emphasis => marks.push((TagEnd::Emphasis, Some(json!({"type":"italic"})))), Tag::Strong => marks.push((TagEnd::Strong, Some(json!({"type":"bold"})))), Tag::Strikethrough => { marks.push((TagEnd::Strikethrough, Some(json!({"type":"strike"})))) } Tag::Link { dest_url, .. } => marks.push(( TagEnd::Link, safe_markdown_url(&dest_url, material, false) .map(|href| json!({"type":"link","attrs":{"href":href}})), )), Tag::Image { dest_url, title, .. } => image = Some((dest_url.to_string(), title.to_string(), String::new())), Tag::Table(_) => stack.push(MarkdownFrame::new("table", Value::Null)), Tag::TableHead => { table_head = true; stack.push(MarkdownFrame::new("tableRow", Value::Null)); } Tag::TableRow => stack.push(MarkdownFrame::new("tableRow", Value::Null)), Tag::TableCell => stack.push(MarkdownFrame::new( if table_head { "tableHeader" } else { "tableCell" }, json!({"colspan":1,"rowspan":1}), )), _ => {} }, Event::End(end) => match end { TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough | TagEnd::Link => { if let Some(index) = marks.iter().rposition(|(kind, _)| *kind == end) { marks.remove(index); } } TagEnd::Image => { if let Some((url, title, alt)) = image.take() { if let Some(src) = safe_markdown_url(&url, material, true) { append_block( &mut stack, json!({"type":"image","attrs":{"src":src,"alt":alt,"title":title}}), ); } else { append_inline(&mut stack, json!({"type":"text","text":alt})); } } } TagEnd::Paragraph | TagEnd::Heading(_) | TagEnd::BlockQuote(_) | TagEnd::List(_) | TagEnd::Item | TagEnd::CodeBlock | TagEnd::HtmlBlock | TagEnd::Table | TagEnd::TableHead | TagEnd::TableRow | TagEnd::TableCell => { if end == TagEnd::TableHead { table_head = false; } if stack.len() > 1 { let frame = stack.pop().unwrap(); if frame.kind != "paragraph" || !frame.content.is_empty() { append_block(&mut stack, frame.finish()); } } } _ => {} }, Event::InlineHtml(html) if matches!(html.trim(), "
" | "
" | "
") => { append_inline(&mut stack, json!({"type":"hardBreak"})) } Event::InlineHtml(html) if html.trim() == "" => { underline_depth += 1; } Event::InlineHtml(html) if html.trim() == "" => { underline_depth = underline_depth.saturating_sub(1); } Event::Text(text) | Event::Html(text) | Event::InlineHtml(text) => { if let Some((_, _, alt)) = &mut image { alt.push_str(&text); continue; } if text.is_empty() { continue; } let mut node = json!({"type":"text","text":text.to_string()}); if stack.last().is_some_and(|frame| frame.kind != "codeBlock") { let mut active: Vec = marks.iter().filter_map(|(_, mark)| mark.clone()).collect(); if underline_depth > 0 { active.push(json!({"type":"underline"})); } if !active.is_empty() { node["marks"] = json!(active); } } append_inline(&mut stack, node); } Event::Code(text) => { if let Some((_, _, alt)) = &mut image { alt.push_str(&text); continue; } let mut active: Vec = marks.iter().filter_map(|(_, mark)| mark.clone()).collect(); active.push(json!({"type":"code"})); append_inline( &mut stack, json!({"type":"text","text":text.to_string(),"marks":active}), ); } Event::InlineMath(formula) => { let mut node = json!({"type":"inlineLatex","attrs":{"formula":formula.to_string()}}); let mut active: Vec = marks.iter().filter_map(|(_, mark)| mark.clone()).collect(); if underline_depth > 0 { active.push(json!({"type":"underline"})); } if !active.is_empty() { node["marks"] = json!(active); } append_inline(&mut stack, node); } Event::DisplayMath(formula) => append_block( &mut stack, json!({"type":"blockLatex","attrs":{"formula":formula.to_string()}}), ), Event::SoftBreak => append_inline(&mut stack, json!({"type":"text","text":"\n"})), Event::HardBreak => append_inline(&mut stack, json!({"type":"hardBreak"})), Event::Rule => append_block(&mut stack, json!({"type":"horizontalRule"})), Event::TaskListMarker(checked) => { if let Some(item) = stack .iter_mut() .rev() .find(|f| matches!(f.kind.as_str(), "listItem" | "taskItem")) { item.kind = "taskItem".into(); item.attrs = json!({"checked":checked}); } if let Some(list) = stack .iter_mut() .rev() .find(|f| matches!(f.kind.as_str(), "bulletList" | "taskList")) { list.kind = "taskList".into(); } } Event::FootnoteReference(label) => append_inline( &mut stack, json!({"type":"text","text":format!("[^{label}]")}), ), } } let mut document = stack.pop().unwrap().finish(); if document["content"].as_array().is_some_and(Vec::is_empty) { document["content"] = json!([{"type":"paragraph","content":[]}]); } document } async fn validate_card_input( state: &AppState, material: &Material, input: &CardInput, ) -> Result<(), ApiError> { bounded_text(&input.question, "question", 12000, true)?; bounded_text(&input.answer, "answer", 20000, true)?; validate_location(material, &input.quote, input.page)?; if let Some(note) = input.note_id { let exists: bool = sqlx::query_scalar( "select exists(select 1 from reader_notes where id=$1 and material_id=$2)", ) .bind(note) .bind(material.id) .fetch_one(&state.platform_db) .await?; if !exists { return Err(ApiError::BadRequest( "source note does not belong to this material".into(), )); } } Ok(()) } async fn create_card( State(state): State, Path(id): Path, Json(input): Json, ) -> Result<(axum::http::StatusCode, Json), ApiError> { let material = get_material(&state, id).await?; validate_card_input(&state, &material, &input).await?; let row=sqlx::query_as::<_,ReviewCard>("insert into reader_review_cards(material_id,question,answer,quote,page,note_id) values($1,$2,$3,$4,$5,$6) returning *") .bind(id).bind(input.question.trim()).bind(input.answer.trim()).bind(input.quote).bind(input.page).bind(input.note_id).fetch_one(&state.platform_db).await?; Ok((axum::http::StatusCode::CREATED, Json(row))) } async fn update_card( State(state): State, Path(id): Path, Json(input): Json, ) -> Result, ApiError> { let previous = sqlx::query_as::<_, ReviewCard>("select * from reader_review_cards where id=$1") .bind(id) .fetch_one(&state.platform_db) .await?; let material = get_material(&state, previous.material_id).await?; validate_card_input(&state, &material, &input).await?; Ok(Json(sqlx::query_as::<_,ReviewCard>("update reader_review_cards set question=$2,answer=$3,quote=$4,page=$5,note_id=$6,updated_at=now() where id=$1 returning *") .bind(id).bind(input.question.trim()).bind(input.answer.trim()).bind(input.quote).bind(input.page).bind(input.note_id).fetch_one(&state.platform_db).await?)) } async fn delete_card( State(state): State, Path(id): Path, ) -> Result { sqlx::query("delete from reader_review_cards where id=$1") .bind(id) .execute(&state.platform_db) .await?; Ok(axum::http::StatusCode::NO_CONTENT) } async fn review(State(state): State) -> Result, ApiError> { let cards:Vec=sqlx::query_scalar("select to_jsonb(result) from (select c.*,m.title material_title,m.kind material_kind from reader_review_cards c join reader_materials m on m.id=c.material_id order by c.due_at) result").fetch_all(&state.platform_db).await?; let due = cards .iter() .filter(|c| { c["due_at"] .as_str() .and_then(|d| DateTime::parse_from_rfc3339(d).ok()) .is_some_and(|d| d <= Utc::now()) }) .count(); Ok(Json(json!({"cards":cards,"due":due}))) } pub(crate) fn next_review( interval: f64, repetitions: i32, grade: &str, time: DateTime, ) -> Result<(f64, i32, DateTime), ApiError> { let days = match grade { "again" => 0.0, "hard" => (interval * 1.2).max(1.0), "good" => { if interval > 0.0 { (interval * 2.0).max(3.0) } else { 1.0 } } "easy" => { if interval > 0.0 { (interval * 2.5).max(4.0) } else { 4.0 } } _ => { return Err(ApiError::BadRequest( "grade must be again, hard, good or easy".into(), )); } }; let days = (days * 100.0).round() / 100.0; let milliseconds = if grade == "again" { 600000 } else { (days * 86400000.0).round() as i64 }; let due = time .checked_add_signed(Duration::milliseconds(milliseconds)) .ok_or_else(|| ApiError::BadRequest("review interval is too large".into()))?; Ok(( days, if grade == "again" { 0 } else { repetitions.saturating_add(1) }, due, )) } async fn review_card( State(state): State, Path(id): Path, Json(input): Json, ) -> Result, ApiError> { let grade = input["grade"] .as_str() .ok_or_else(|| ApiError::BadRequest("grade is required".into()))?; let mut tx = state.platform_db.begin().await?; let card = sqlx::query_as::<_, ReviewCard>("select * from reader_review_cards where id=$1 for update") .bind(id) .fetch_one(&mut *tx) .await?; if card.due_at > Utc::now() { return Err(ApiError::Conflict("this card is not due yet".into())); } let (interval, repetitions, due) = next_review(card.interval_days, card.repetitions, grade, Utc::now())?; let row=sqlx::query_as::<_,ReviewCard>("update reader_review_cards set due_at=$2,interval_days=$3,repetitions=$4,updated_at=now() where id=$1 returning *").bind(id).bind(due).bind(interval).bind(repetitions).fetch_one(&mut *tx).await?; tx.commit().await?; Ok(Json(row)) } #[cfg(test)] mod tests { use super::*; #[test] fn review_schedule_preserves_intervals_and_reset() { let now = Utc::now(); let good = next_review(4.0, 2, "good", now).unwrap(); assert_eq!(good.0, 8.0); assert_eq!(good.1, 3); let again = next_review(20.0, 8, "again", now).unwrap(); assert_eq!(again.0, 0.0); assert_eq!(again.1, 0); assert_eq!((again.2 - now).num_seconds(), 600); assert_eq!(next_review(0.0, 0, "easy", now).unwrap().0, 4.0); assert!(next_review(4.0, 2, "invalid", now).is_err()); } #[test] fn capture_markdown_is_readable_and_preserves_code_math_and_safe_links() { let document = markdown_document( "## Memory\n\n**Frames** preserve $offset$.\n\n- First\n- Second\n\n```rust\nlet x = 1;\n```\n\n[unsafe](javascript:alert(1))", Uuid::new_v4(), ); let content = document["content"].as_array().unwrap(); assert_eq!(content[0]["type"], "heading"); assert_eq!(content[1]["type"], "paragraph"); assert_eq!(content[1]["content"][0]["marks"][0]["type"], "bold"); assert!( content[1]["content"] .as_array() .unwrap() .iter() .any(|n| n["type"] == "inlineLatex") ); assert_eq!(content[2]["type"], "bulletList"); assert_eq!(content[2]["content"][0]["content"][0]["type"], "paragraph"); assert_eq!(content[3]["type"], "codeBlock"); assert!(!document.to_string().contains("javascript:")); } }