Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
23 kB · 763 lines
Rust
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764use 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<String>,}
#[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<String>,}
#[derive(Debug, Clone, Serialize)]pub struct KnowledgeGraph { pub nodes: Vec<GraphNode>, pub edges: Vec<GraphEdge>,}
#[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<Utc>, pub updated_at: DateTime<Utc>,}
#[derive(Debug, Serialize)]pub struct NoteResponse { #[serde(flatten)] pub note: NoteRecord, pub reader_material_id: Option<Uuid>, pub tags: Vec<String>, pub backlinks: Vec<NoteLinkSummary>,}
#[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<String>, pub tag: Option<String>, pub kind: Option<String>,}
#[derive(Debug, Deserialize)]pub struct CreateNoteRequest { pub title: String, pub kind: Option<String>, pub excerpt: Option<String>, pub document: Option<Value>, pub tags: Option<Vec<String>>,}
#[derive(Debug, Deserialize)]pub struct UpdateNoteRequest { pub expected_version: i64, pub title: Option<String>, pub kind: Option<String>, pub excerpt: Option<String>, pub document: Option<Value>, pub tags: Option<Vec<String>>, pub reason: Option<String>,}
#[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<Uuid>, pub revision_number: i64, pub title: String, pub excerpt: String, pub document: Value, pub reason: String, pub created_at: DateTime<Utc>,}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]pub struct AnnotationRecord { pub id: Uuid, pub note_id: Uuid, pub block_id: String, pub anchor: Option<Value>, pub messages: Value, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,}
#[derive(Debug, Deserialize)]pub struct CreateAnnotationRequest { pub block_id: String, pub anchor: Option<Value>, pub message: String,}
#[derive(Debug, Deserialize)]pub struct AddAnnotationMessageRequest { pub message: String,}
pub async fn list_notes( State(state): State<AppState>, Query(filter): Query<NoteListQuery>,) -> Result<Json<Vec<NoteResponse>>, 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<AppState>, Path(id): Path<Uuid>,) -> Result<Json<NoteResponse>, ApiError> { let note = fetch_note(&state, id).await?; Ok(Json(load_note_response(&state, note).await?))}
pub async fn create_note( State(state): State<AppState>, Json(input): Json<CreateNoteRequest>,) -> Result<Json<NoteResponse>, 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<AppState>, Path(id): Path<Uuid>, Json(input): Json<UpdateNoteRequest>,) -> Result<Json<NoteResponse>, 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<Uuid> = 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<AppState>, Path(id): Path<Uuid>,) -> Result<Json<Value>, 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<AppState>, Path(id): Path<Uuid>,) -> Result<Json<Vec<RevisionRecord>>, 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<AppState>, Path(note_id): Path<Uuid>,) -> Result<Json<Vec<AnnotationRecord>>, 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<AppState>, Path(note_id): Path<Uuid>, Json(input): Json<CreateAnnotationRequest>,) -> Result<Json<AnnotationRecord>, 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<AppState>, Path(id): Path<Uuid>, Json(input): Json<AddAnnotationMessageRequest>,) -> Result<Json<AnnotationRecord>, 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<AppState>, Path(id): Path<Uuid>,) -> Result<Json<Value>, 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<AppState>, Path((note_id, revision_id)): Path<(Uuid, Uuid)>, Json(input): Json<RestoreRevisionRequest>,) -> Result<Json<NoteResponse>, 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<Uuid, Vec<String>>,) -> Vec<GraphEdge> { let mut members_by_tag = BTreeMap::<String, Vec<Uuid>>::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<String>>::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<AppState>) -> Result<Json<KnowledgeGraph>, 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::<Uuid, Vec<String>>::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<NoteRecord, ApiError> { 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<NoteResponse, ApiError> { 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<Vec<String>, 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<String>,) -> 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<String, ApiError> { 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<Value, ApiError> { 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::<Vec<_>>(); let tags_by_note = nodes .iter() .map(|node| (node.id, vec!["rust".to_owned()])) .collect::<HashMap<_, _>>();
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::<HashMap<_, _>>();
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()); }}