Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
29 kB · 734 lines
Rust
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735use 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<AppState> { 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<i32>, pub chat_id: Option<Uuid>, pub message_id: Option<Uuid>, pub citations: Value, pub captured_note_id: Option<Uuid>, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,}#[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<i32>, pub note_id: Option<Uuid>, pub due_at: DateTime<Utc>, pub interval_days: f64, pub repetitions: i32, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,}#[derive(Debug, Deserialize)]#[serde(rename_all = "camelCase")]struct NoteInput { title: String, body: String, #[serde(default)] quote: String, page: Option<i32>, chat_id: Option<Uuid>, message_id: Option<Uuid>,}#[derive(Debug, Deserialize)]#[serde(rename_all = "camelCase")]struct CardInput { question: String, answer: String, #[serde(default)] quote: String, page: Option<i32>, note_id: Option<Uuid>,}pub(crate) fn validate_location( material: &Material, quote: &str, page: Option<i32>,) -> 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<AppState>, Path(id): Path<Uuid>,) -> Result<Json<Value>, 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<Value>=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<AppState>, Path(id): Path<Uuid>, Json(input): Json<Value>,) -> Result<axum::http::StatusCode, ApiError> { 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<ReaderNote>,) -> Result<ReaderNote, ApiError> { 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<Value>=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<AppState>, Path(id): Path<Uuid>, Json(input): Json<NoteInput>,) -> Result<(axum::http::StatusCode, Json<ReaderNote>), 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<AppState>, Path(id): Path<Uuid>, Json(input): Json<NoteInput>,) -> Result<Json<ReaderNote>, 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<AppState>, Path(id): Path<Uuid>,) -> Result<axum::http::StatusCode, ApiError> { 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<AppState>, Path(id): Path<Uuid>,) -> Result<Json<Value>, 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<Value>,}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<String> { 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<Value>)> = 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(), "<br>" | "<br/>" | "<br />") => { append_inline(&mut stack, json!({"type":"hardBreak"})) } Event::InlineHtml(html) if html.trim() == "<u>" => { underline_depth += 1; } Event::InlineHtml(html) if html.trim() == "</u>" => { 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<Value> = 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<Value> = 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<Value> = 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<AppState>, Path(id): Path<Uuid>, Json(input): Json<CardInput>,) -> Result<(axum::http::StatusCode, Json<ReviewCard>), 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<AppState>, Path(id): Path<Uuid>, Json(input): Json<CardInput>,) -> Result<Json<ReviewCard>, 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<AppState>, Path(id): Path<Uuid>,) -> Result<axum::http::StatusCode, ApiError> { 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<AppState>) -> Result<Json<Value>, ApiError> { let cards:Vec<Value>=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<Utc>,) -> Result<(f64, i32, DateTime<Utc>), 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<AppState>, Path(id): Path<Uuid>, Json(input): Json<Value>,) -> Result<Json<ReviewCard>, 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:")); }}