use crate::{error::ApiError, state::AppState}; use axum::{ Json, extract::{Path, Query, State}, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sqlx::{Postgres, Transaction}; use std::collections::{BTreeMap, BTreeSet, HashMap}; use uuid::Uuid; #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "snake_case")] pub enum NoteKind { Card, Post, } #[derive(Debug, Clone, Serialize)] pub struct GraphNode { pub id: Uuid, pub slug: String, pub title: String, pub kind: NoteKind, pub tags: Vec, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "snake_case")] pub enum GraphEdgeKind { TagAffinity, ExplicitLink, } #[derive(Debug, Clone, Serialize)] pub struct GraphEdge { pub source: Uuid, pub target: Uuid, pub kind: GraphEdgeKind, pub shared_tags: Vec, } #[derive(Debug, Clone, Serialize)] pub struct KnowledgeGraph { pub nodes: Vec, pub edges: Vec, } #[derive(Debug, Clone, Serialize, sqlx::FromRow)] pub struct NoteRecord { pub id: Uuid, pub slug: String, pub title: String, pub kind: String, pub status: String, pub excerpt: String, pub document: Value, pub version: i64, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Serialize)] pub struct NoteResponse { #[serde(flatten)] pub note: NoteRecord, pub reader_material_id: Option, pub tags: Vec, pub backlinks: Vec, } #[derive(Debug, Serialize, sqlx::FromRow)] pub struct NoteLinkSummary { pub id: Uuid, pub slug: String, pub title: String, } #[derive(Debug, Deserialize)] pub struct NoteListQuery { pub query: Option, pub tag: Option, pub kind: Option, } #[derive(Debug, Deserialize)] pub struct CreateNoteRequest { pub title: String, pub kind: Option, pub excerpt: Option, pub document: Option, pub tags: Option>, } #[derive(Debug, Deserialize)] pub struct UpdateNoteRequest { pub expected_version: i64, pub title: Option, pub kind: Option, pub excerpt: Option, pub document: Option, pub tags: Option>, pub reason: Option, } #[derive(Debug, Deserialize)] pub struct RestoreRevisionRequest { pub expected_version: i64, } #[derive(Debug, Serialize, sqlx::FromRow)] pub struct RevisionRecord { pub id: Uuid, pub note_id: Uuid, pub parent_revision_id: Option, pub revision_number: i64, pub title: String, pub excerpt: String, pub document: Value, pub reason: String, pub created_at: DateTime, } #[derive(Debug, Clone, Serialize, sqlx::FromRow)] pub struct AnnotationRecord { pub id: Uuid, pub note_id: Uuid, pub block_id: String, pub anchor: Option, pub messages: Value, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Deserialize)] pub struct CreateAnnotationRequest { pub block_id: String, pub anchor: Option, pub message: String, } #[derive(Debug, Deserialize)] pub struct AddAnnotationMessageRequest { pub message: String, } pub async fn list_notes( State(state): State, Query(filter): Query, ) -> Result>, ApiError> { let query = filter.query.unwrap_or_default(); let kind = filter.kind.unwrap_or_default(); let tag = filter.tag.unwrap_or_default(); let rows = sqlx::query_as::<_, NoteRecord>( "select distinct n.* from notes n left join note_tags nt on nt.note_id = n.id left join tags t on t.id = nt.tag_id where ($1 = '' or n.title ilike '%' || $1 || '%' or n.excerpt ilike '%' || $1 || '%' or t.name ilike '%' || $1 || '%' or $1 = any(t.aliases)) and ($2 = '' or n.kind = $2) and ($3 = '' or t.name = $3) order by n.updated_at desc", ) .bind(query.trim()) .bind(kind) .bind(tag) .fetch_all(&state.platform_db) .await?; let mut result = Vec::with_capacity(rows.len()); for note in rows { result.push(load_note_response(&state, note).await?); } Ok(Json(result)) } pub async fn get_note( State(state): State, Path(id): Path, ) -> Result, ApiError> { let note = fetch_note(&state, id).await?; Ok(Json(load_note_response(&state, note).await?)) } pub async fn create_note( State(state): State, Json(input): Json, ) -> Result, ApiError> { let title = input.title.trim(); if title.is_empty() { return Err(ApiError::BadRequest("title is required".into())); } let kind = valid_kind(input.kind.as_deref().unwrap_or("card"))?; let excerpt = input.excerpt.unwrap_or_default(); let document = input.document.unwrap_or_else(empty_document); let id = Uuid::now_v7(); let slug = unique_slug(&state, title, id).await?; let mut tx = state.platform_db.begin().await?; let note = sqlx::query_as::<_, NoteRecord>( "insert into notes (id, slug, title, kind, excerpt, document) values ($1, $2, $3, $4, $5, $6) returning *", ) .bind(id) .bind(slug) .bind(title) .bind(kind) .bind(excerpt) .bind(&document) .fetch_one(&mut *tx) .await?; sqlx::query( "insert into note_revisions (note_id, revision_number, title, excerpt, document, reason) values ($1, 1, $2, $3, $4, 'created')", ) .bind(id) .bind(¬e.title) .bind(¬e.excerpt) .bind(¬e.document) .execute(&mut *tx) .await?; replace_tags(&mut tx, id, input.tags.unwrap_or_default()).await?; tx.commit().await?; Ok(Json(load_note_response(&state, note).await?)) } pub async fn update_note( State(state): State, Path(id): Path, Json(input): Json, ) -> Result, ApiError> { let mut tx = state.platform_db.begin().await?; // Reader writes lock their projection before the native Note. Use the // same order here so simultaneous saves serialize without deadlocks. let linked = sqlx::query_as::<_, crate::reader::Material>( "select * from reader_materials where note_id=$1 for update", ) .bind(id) .fetch_optional(&mut *tx) .await?; let current = sqlx::query_as::<_, NoteRecord>("select * from notes where id=$1 for update") .bind(id) .fetch_one(&mut *tx) .await?; let title = input.title.unwrap_or_else(|| current.title.clone()); let kind = valid_kind(input.kind.as_deref().unwrap_or(¤t.kind))?; let document = input.document.unwrap_or_else(|| current.document.clone()); let excerpt = input.excerpt.unwrap_or_else(|| { if linked.is_some() && document != current.document { crate::reader_native::excerpt(&document) } else { current.excerpt.clone() } }); let next_version = input.expected_version + 1; let note = sqlx::query_as::<_, NoteRecord>( "update notes set title = $3, kind = $4, excerpt = $5, document = $6, version = version + 1, updated_at = now() where id = $1 and version = $2 returning *", ) .bind(id) .bind(input.expected_version) .bind(title.trim()) .bind(kind) .bind(excerpt) .bind(&document) .fetch_optional(&mut *tx) .await? .ok_or_else(|| { ApiError::Conflict("note changed on another device; reload before saving".into()) })?; let parent_revision_id: Option = sqlx::query_scalar( "select id from note_revisions where note_id = $1 order by revision_number desc limit 1", ) .bind(id) .fetch_optional(&mut *tx) .await?; sqlx::query( "insert into note_revisions (note_id, parent_revision_id, revision_number, title, excerpt, document, reason) values ($1, $2, $3, $4, $5, $6, $7)", ) .bind(id) .bind(parent_revision_id) .bind(next_version) .bind(¬e.title) .bind(¬e.excerpt) .bind(¬e.document) .bind(input.reason.unwrap_or_else(|| "autosave".into())) .execute(&mut *tx) .await?; if let Some(tags) = input.tags { replace_tags(&mut tx, id, tags).await?; } if let Some(material) = linked { crate::reader_native::sync_note_material_tx( &mut tx, ¬e, ¤t.document, &material, "native note edit", ) .await?; } tx.commit().await?; Ok(Json(load_note_response(&state, note).await?)) } pub async fn delete_note( State(state): State, Path(id): Path, ) -> Result, ApiError> { let mut tx = state.platform_db.begin().await?; let linked = sqlx::query_as::<_, crate::reader::Material>( "select * from reader_materials where note_id=$1 for update", ) .bind(id) .fetch_optional(&mut *tx) .await?; if let Some(material) = &linked { crate::reader_native::delete_projection_tx(&mut tx, material).await?; } let affected = sqlx::query("delete from notes where id = $1") .bind(id) .execute(&mut *tx) .await? .rows_affected(); if affected == 0 { return Err(ApiError::NotFound("note not found".into())); } tx.commit().await?; if let Some(material) = linked { let _ = tokio::fs::remove_dir_all( state .config .storage_dir .join("reader") .join(material.id.to_string()), ) .await; } Ok(Json(json!({"ok": true}))) } pub async fn list_revisions( State(state): State, Path(id): Path, ) -> Result>, ApiError> { let rows = sqlx::query_as::<_, RevisionRecord>( "select * from note_revisions where note_id = $1 order by revision_number desc", ) .bind(id) .fetch_all(&state.platform_db) .await?; Ok(Json(rows)) } pub async fn list_annotations( State(state): State, Path(note_id): Path, ) -> Result>, ApiError> { Ok(Json( sqlx::query_as::<_, AnnotationRecord>( "select * from annotations where note_id = $1 order by created_at", ) .bind(note_id) .fetch_all(&state.platform_db) .await?, )) } pub async fn create_annotation( State(state): State, Path(note_id): Path, Json(input): Json, ) -> Result, ApiError> { let message = annotation_message(input.message)?; let block_id = input.block_id.trim(); if block_id.is_empty() { return Err(ApiError::BadRequest("block_id is required".into())); } let row = sqlx::query_as::<_, AnnotationRecord>( "insert into annotations (note_id, block_id, anchor, messages) values ($1, $2, $3, $4) returning *", ) .bind(note_id) .bind(block_id) .bind(input.anchor) .bind(json!([message])) .fetch_one(&state.platform_db) .await?; Ok(Json(row)) } pub async fn add_annotation_message( State(state): State, Path(id): Path, Json(input): Json, ) -> Result, ApiError> { let message = annotation_message(input.message)?; let row = sqlx::query_as::<_, AnnotationRecord>( "update annotations set messages = messages || $2::jsonb, updated_at = now() where id = $1 returning *", ) .bind(id) .bind(json!([message])) .fetch_one(&state.platform_db) .await?; Ok(Json(row)) } pub async fn delete_annotation( State(state): State, Path(id): Path, ) -> Result, ApiError> { let affected = sqlx::query("delete from annotations where id = $1") .bind(id) .execute(&state.platform_db) .await? .rows_affected(); if affected == 0 { return Err(ApiError::NotFound("annotation not found".into())); } Ok(Json(json!({"ok": true}))) } pub async fn restore_revision( State(state): State, Path((note_id, revision_id)): Path<(Uuid, Uuid)>, Json(input): Json, ) -> Result, ApiError> { let revision = sqlx::query_as::<_, RevisionRecord>( "select * from note_revisions where id = $1 and note_id = $2", ) .bind(revision_id) .bind(note_id) .fetch_one(&state.platform_db) .await?; update_note( State(state), Path(note_id), Json(UpdateNoteRequest { expected_version: input.expected_version, title: Some(revision.title), kind: None, excerpt: Some(revision.excerpt), document: Some(revision.document), tags: None, reason: Some(format!("restored_from:{}", revision.id)), }), ) .await } fn build_tag_affinity_edges( nodes: &[GraphNode], tags_by_note: &HashMap>, ) -> Vec { let mut members_by_tag = BTreeMap::>::new(); for node in nodes { for tag in tags_by_note.get(&node.id).into_iter().flatten() { members_by_tag .entry(tag.trim().to_lowercase()) .or_default() .push(node.id); } } // A cluster boundary already communicates that every member shares the // same tag. Rendering every pair would create O(n²) edges and payload // growth without adding information. A deterministic chain keeps each tag // cluster connected with O(n) edges. When the same pair is adjacent for // several tags, those tags are merged into one semantic edge. let mut shared_tags_by_edge = BTreeMap::<(Uuid, Uuid), BTreeSet>::new(); for (tag, mut members) in members_by_tag { members.sort_unstable(); members.dedup(); for pair in members.windows(2) { let (source, target) = if pair[0] <= pair[1] { (pair[0], pair[1]) } else { (pair[1], pair[0]) }; shared_tags_by_edge .entry((source, target)) .or_default() .insert(tag.clone()); } } shared_tags_by_edge .into_iter() .map(|((source, target), shared_tags)| GraphEdge { source, target, kind: GraphEdgeKind::TagAffinity, shared_tags: shared_tags.into_iter().collect(), }) .collect() } pub async fn graph(State(state): State) -> Result, ApiError> { let notes = sqlx::query_as::<_, (Uuid, String, String, String)>( "select id, slug, title, kind from notes order by updated_at desc", ) .fetch_all(&state.platform_db) .await?; let tag_rows = sqlx::query_as::<_, (Uuid, String)>( "select nt.note_id, t.display_name from note_tags nt join tags t on t.id = nt.tag_id order by t.display_name", ) .fetch_all(&state.platform_db) .await?; let mut tags_by_note = HashMap::>::new(); for (note_id, tag) in tag_rows { tags_by_note.entry(note_id).or_default().push(tag); } let mut nodes = Vec::with_capacity(notes.len()); for (id, slug, title, kind) in notes { let tags = tags_by_note.get(&id).cloned().unwrap_or_default(); nodes.push(GraphNode { id, slug, title, kind: parse_kind(&kind), tags, }); } let mut edges = build_tag_affinity_edges(&nodes, &tags_by_note); let explicit = sqlx::query_as::<_, (Uuid, Uuid)>( "select distinct source_note_id, target_note_id from note_links", ) .fetch_all(&state.platform_db) .await?; edges.extend(explicit.into_iter().map(|(source, target)| GraphEdge { source, target, kind: GraphEdgeKind::ExplicitLink, shared_tags: Vec::new(), })); Ok(Json(KnowledgeGraph { nodes, edges })) } async fn fetch_note(state: &AppState, id: Uuid) -> Result { Ok( sqlx::query_as::<_, NoteRecord>("select * from notes where id = $1") .bind(id) .fetch_one(&state.platform_db) .await?, ) } async fn load_note_response(state: &AppState, note: NoteRecord) -> Result { let reader_material_id = sqlx::query_scalar("select id from reader_materials where note_id=$1") .bind(note.id) .fetch_optional(&state.platform_db) .await?; let tags = fetch_tags(state, note.id).await?; let backlinks = sqlx::query_as::<_, NoteLinkSummary>( "select n.id, n.slug, n.title from note_links l join notes n on n.id = l.source_note_id where l.target_note_id = $1 order by n.title", ) .bind(note.id) .fetch_all(&state.platform_db) .await?; Ok(NoteResponse { note, reader_material_id, tags, backlinks, }) } async fn fetch_tags(state: &AppState, note_id: Uuid) -> Result, ApiError> { Ok(sqlx::query_scalar( "select t.display_name from note_tags nt join tags t on t.id = nt.tag_id where nt.note_id = $1 order by t.display_name", ) .bind(note_id) .fetch_all(&state.platform_db) .await?) } pub(crate) async fn replace_tags( tx: &mut Transaction<'_, Postgres>, note_id: Uuid, tags: Vec, ) -> Result<(), ApiError> { sqlx::query("delete from note_tags where note_id = $1") .bind(note_id) .execute(&mut **tx) .await?; for raw in tags { let display_name = raw.trim(); if display_name.is_empty() { continue; } let name = display_name.to_lowercase(); let tag_id: Uuid = sqlx::query_scalar( "insert into tags (name, display_name) values ($1, $2) on conflict (name) do update set display_name = excluded.display_name returning id", ) .bind(name) .bind(display_name) .fetch_one(&mut **tx) .await?; sqlx::query( "insert into note_tags (note_id, tag_id) values ($1, $2) on conflict do nothing", ) .bind(note_id) .bind(tag_id) .execute(&mut **tx) .await?; } Ok(()) } async fn unique_slug(state: &AppState, title: &str, id: Uuid) -> Result { let base = slugify(title); let base = if base.is_empty() { format!("note-{}", &id.to_string()[..8]) } else { base }; let exists: bool = sqlx::query_scalar("select exists(select 1 from notes where slug = $1)") .bind(&base) .fetch_one(&state.platform_db) .await?; Ok(if exists { format!("{}-{}", base, &id.to_string()[..8]) } else { base }) } fn slugify(title: &str) -> String { let mut slug = String::new(); let mut separator = false; for character in title.chars().flat_map(char::to_lowercase) { if character.is_alphanumeric() { slug.push(character); separator = false; } else if !separator && !slug.is_empty() { slug.push('-'); separator = true; } } slug.trim_matches('-').to_owned() } fn valid_kind(value: &str) -> Result<&str, ApiError> { match value { "card" | "post" => Ok(value), _ => Err(ApiError::BadRequest("kind must be card or post".into())), } } fn empty_document() -> Value { json!({"schema_version": 1, "content": {"type": "doc", "content": []}}) } fn annotation_message(body: String) -> Result { let body = body.trim(); if body.is_empty() { return Err(ApiError::BadRequest( "annotation message is required".into(), )); } Ok(json!({ "id": Uuid::now_v7(), "body": body, "created_at": Utc::now(), "author": "owner" })) } fn parse_kind(value: &str) -> NoteKind { match value { "post" => NoteKind::Post, _ => NoteKind::Card, } } #[cfg(test)] mod graph_edge_tests { use super::*; fn graph_node(index: u128) -> GraphNode { GraphNode { id: Uuid::from_u128(index), slug: format!("note-{index}"), title: format!("Note {index}"), kind: NoteKind::Card, tags: Vec::new(), } } #[test] fn one_large_tag_produces_a_linear_backbone() { let nodes = (1..=100).map(graph_node).collect::>(); let tags_by_note = nodes .iter() .map(|node| (node.id, vec!["rust".to_owned()])) .collect::>(); let edges = build_tag_affinity_edges(&nodes, &tags_by_note); assert_eq!(edges.len(), nodes.len() - 1); assert!(edges.iter().all(|edge| edge.shared_tags == ["rust"])); } #[test] fn shared_memberships_are_merged_on_one_edge() { let nodes = vec![graph_node(1), graph_node(2)]; let tags_by_note = nodes .iter() .map(|node| { ( node.id, vec!["mathematics".to_owned(), "machine-learning".to_owned()], ) }) .collect::>(); let edges = build_tag_affinity_edges(&nodes, &tags_by_note); assert_eq!(edges.len(), 1); assert_eq!(edges[0].shared_tags, ["machine-learning", "mathematics"]); } #[test] fn untagged_nodes_do_not_add_edges_or_panic() { let nodes = vec![graph_node(1), graph_node(2), graph_node(3)]; let mut tags_by_note = HashMap::new(); tags_by_note.insert(nodes[0].id, vec!["rust".to_owned()]); tags_by_note.insert(nodes[1].id, vec!["rust".to_owned()]); let edges = build_tag_affinity_edges(&nodes, &tags_by_note); assert_eq!(edges.len(), 1); assert_eq!(edges[0].source, nodes[0].id); assert_eq!(edges[0].target, nodes[1].id); assert_eq!(edges[0].shared_tags, ["rust"]); assert!(build_tag_affinity_edges(&nodes, &HashMap::new()).is_empty()); } }