various: markdown improvements
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I81fda8247814da19eed1e76dbe97bd5b6a6a6964
This commit is contained in:
parent
875bdf5ebc
commit
80a8b5c7ca
23 changed files with 3458 additions and 30 deletions
|
|
@ -231,7 +231,17 @@ pub fn create_router_with_tls(
|
|||
.route(
|
||||
"/notifications/shares",
|
||||
get(routes::shares::get_notifications),
|
||||
);
|
||||
)
|
||||
// Markdown notes/links (read)
|
||||
.route(
|
||||
"/media/{id}/backlinks",
|
||||
get(routes::notes::get_backlinks),
|
||||
)
|
||||
.route(
|
||||
"/media/{id}/outgoing-links",
|
||||
get(routes::notes::get_outgoing_links),
|
||||
)
|
||||
.nest("/notes", routes::notes::routes());
|
||||
|
||||
// Write routes: Editor+ required
|
||||
let editor_routes = Router::new()
|
||||
|
|
@ -281,6 +291,11 @@ pub fn create_router_with_tls(
|
|||
"/media/{id}/custom-fields/{name}",
|
||||
delete(routes::media::delete_custom_field),
|
||||
)
|
||||
// Markdown notes/links (write)
|
||||
.route(
|
||||
"/media/{id}/reindex-links",
|
||||
post(routes::notes::reindex_links),
|
||||
)
|
||||
.route("/tags", post(routes::tags::create_tag))
|
||||
.route("/tags/{id}", delete(routes::tags::delete_tag))
|
||||
.route("/media/{media_id}/tags", post(routes::tags::tag_media))
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ pub mod health;
|
|||
pub mod integrity;
|
||||
pub mod jobs;
|
||||
pub mod media;
|
||||
pub mod notes;
|
||||
pub mod photos;
|
||||
pub mod playlists;
|
||||
pub mod plugins;
|
||||
|
|
|
|||
316
crates/pinakes-server/src/routes/notes.rs
Normal file
316
crates/pinakes-server/src/routes/notes.rs
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
//! API endpoints for Obsidian-style markdown notes features.
|
||||
//!
|
||||
//! Provides endpoints for:
|
||||
//! - Backlinks (what links to this note)
|
||||
//! - Outgoing links (what this note links to)
|
||||
//! - Graph visualization data
|
||||
//! - Link reindexing
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use pinakes_core::model::{BacklinkInfo, GraphData, GraphEdge, GraphNode, MarkdownLink, MediaId};
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
// ===== Response DTOs =====
|
||||
|
||||
/// Response for backlinks query
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BacklinksResponse {
|
||||
pub backlinks: Vec<BacklinkItem>,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
/// Individual backlink item
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BacklinkItem {
|
||||
pub link_id: Uuid,
|
||||
pub source_id: Uuid,
|
||||
pub source_title: Option<String>,
|
||||
pub source_path: String,
|
||||
pub link_text: Option<String>,
|
||||
pub line_number: Option<i32>,
|
||||
pub context: Option<String>,
|
||||
pub link_type: String,
|
||||
}
|
||||
|
||||
impl From<BacklinkInfo> for BacklinkItem {
|
||||
fn from(info: BacklinkInfo) -> Self {
|
||||
Self {
|
||||
link_id: info.link_id,
|
||||
source_id: info.source_id.0,
|
||||
source_title: info.source_title,
|
||||
source_path: info.source_path,
|
||||
link_text: info.link_text,
|
||||
line_number: info.line_number,
|
||||
context: info.context,
|
||||
link_type: info.link_type.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Response for outgoing links query
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OutgoingLinksResponse {
|
||||
pub links: Vec<OutgoingLinkItem>,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
/// Individual outgoing link item
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OutgoingLinkItem {
|
||||
pub id: Uuid,
|
||||
pub target_path: String,
|
||||
pub target_id: Option<Uuid>,
|
||||
pub link_text: Option<String>,
|
||||
pub line_number: Option<i32>,
|
||||
pub link_type: String,
|
||||
pub is_resolved: bool,
|
||||
}
|
||||
|
||||
impl From<MarkdownLink> for OutgoingLinkItem {
|
||||
fn from(link: MarkdownLink) -> Self {
|
||||
Self {
|
||||
id: link.id,
|
||||
target_path: link.target_path,
|
||||
target_id: link.target_media_id.map(|id| id.0),
|
||||
link_text: link.link_text,
|
||||
line_number: link.line_number,
|
||||
link_type: link.link_type.to_string(),
|
||||
is_resolved: link.target_media_id.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Response for graph visualization
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GraphResponse {
|
||||
pub nodes: Vec<GraphNodeResponse>,
|
||||
pub edges: Vec<GraphEdgeResponse>,
|
||||
pub node_count: usize,
|
||||
pub edge_count: usize,
|
||||
}
|
||||
|
||||
/// Graph node for visualization
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GraphNodeResponse {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub title: Option<String>,
|
||||
pub media_type: String,
|
||||
pub link_count: u32,
|
||||
pub backlink_count: u32,
|
||||
}
|
||||
|
||||
impl From<GraphNode> for GraphNodeResponse {
|
||||
fn from(node: GraphNode) -> Self {
|
||||
Self {
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
title: node.title,
|
||||
media_type: node.media_type,
|
||||
link_count: node.link_count,
|
||||
backlink_count: node.backlink_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Graph edge for visualization
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GraphEdgeResponse {
|
||||
pub source: String,
|
||||
pub target: String,
|
||||
pub link_type: String,
|
||||
}
|
||||
|
||||
impl From<GraphEdge> for GraphEdgeResponse {
|
||||
fn from(edge: GraphEdge) -> Self {
|
||||
Self {
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
link_type: edge.link_type.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GraphData> for GraphResponse {
|
||||
fn from(data: GraphData) -> Self {
|
||||
let node_count = data.nodes.len();
|
||||
let edge_count = data.edges.len();
|
||||
Self {
|
||||
nodes: data.nodes.into_iter().map(GraphNodeResponse::from).collect(),
|
||||
edges: data.edges.into_iter().map(GraphEdgeResponse::from).collect(),
|
||||
node_count,
|
||||
edge_count,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Query parameters for graph endpoint
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GraphQuery {
|
||||
/// Center node ID (optional, if not provided returns entire graph)
|
||||
pub center: Option<Uuid>,
|
||||
/// Depth of traversal from center (default: 2, max: 5)
|
||||
#[serde(default = "default_depth")]
|
||||
pub depth: u32,
|
||||
}
|
||||
|
||||
fn default_depth() -> u32 {
|
||||
2
|
||||
}
|
||||
|
||||
/// Response for reindex operation
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReindexResponse {
|
||||
pub message: String,
|
||||
pub links_extracted: usize,
|
||||
}
|
||||
|
||||
/// Response for link resolution
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ResolveLinksResponse {
|
||||
pub resolved_count: u64,
|
||||
}
|
||||
|
||||
/// Response for unresolved links count
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UnresolvedLinksResponse {
|
||||
pub count: u64,
|
||||
}
|
||||
|
||||
// ===== Handlers =====
|
||||
|
||||
/// Get backlinks (incoming links) to a media item.
|
||||
///
|
||||
/// GET /api/v1/media/{id}/backlinks
|
||||
pub async fn get_backlinks(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<BacklinksResponse>, ApiError> {
|
||||
let media_id = MediaId(id);
|
||||
let backlinks = state.storage.get_backlinks(media_id).await?;
|
||||
|
||||
let items: Vec<BacklinkItem> = backlinks.into_iter().map(BacklinkItem::from).collect();
|
||||
let count = items.len();
|
||||
|
||||
Ok(Json(BacklinksResponse {
|
||||
backlinks: items,
|
||||
count,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get outgoing links from a media item.
|
||||
///
|
||||
/// GET /api/v1/media/{id}/outgoing-links
|
||||
pub async fn get_outgoing_links(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<OutgoingLinksResponse>, ApiError> {
|
||||
let media_id = MediaId(id);
|
||||
let links = state.storage.get_outgoing_links(media_id).await?;
|
||||
|
||||
let items: Vec<OutgoingLinkItem> = links.into_iter().map(OutgoingLinkItem::from).collect();
|
||||
let count = items.len();
|
||||
|
||||
Ok(Json(OutgoingLinksResponse { links: items, count }))
|
||||
}
|
||||
|
||||
/// Get graph data for visualization.
|
||||
///
|
||||
/// GET /api/v1/notes/graph?center={uuid}&depth={n}
|
||||
pub async fn get_graph(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<GraphQuery>,
|
||||
) -> Result<Json<GraphResponse>, ApiError> {
|
||||
let center_id = params.center.map(MediaId);
|
||||
let depth = params.depth.min(5); // Enforce max depth
|
||||
|
||||
let graph_data = state.storage.get_graph_data(center_id, depth).await?;
|
||||
|
||||
Ok(Json(GraphResponse::from(graph_data)))
|
||||
}
|
||||
|
||||
/// Re-extract links from a media item.
|
||||
///
|
||||
/// POST /api/v1/media/{id}/reindex-links
|
||||
pub async fn reindex_links(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ReindexResponse>, ApiError> {
|
||||
let media_id = MediaId(id);
|
||||
|
||||
// Get the media item to read its content
|
||||
let media = state.storage.get_media(media_id).await?;
|
||||
|
||||
// Only process markdown files
|
||||
use pinakes_core::media_type::{BuiltinMediaType, MediaType};
|
||||
match &media.media_type {
|
||||
MediaType::Builtin(BuiltinMediaType::Markdown) => {}
|
||||
_ => {
|
||||
return Ok(Json(ReindexResponse {
|
||||
message: "Skipped: not a markdown file".to_string(),
|
||||
links_extracted: 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Read the file content
|
||||
let content = tokio::fs::read_to_string(&media.path).await.map_err(|e| {
|
||||
ApiError::internal(format!("Failed to read file: {}", e))
|
||||
})?;
|
||||
|
||||
// Extract links
|
||||
let links = pinakes_core::links::extract_links(media_id, &content);
|
||||
let links_count = links.len();
|
||||
|
||||
// Save links to database
|
||||
state.storage.save_markdown_links(media_id, &links).await?;
|
||||
|
||||
// Mark as extracted
|
||||
state.storage.mark_links_extracted(media_id).await?;
|
||||
|
||||
// Try to resolve any unresolved links
|
||||
state.storage.resolve_links().await?;
|
||||
|
||||
Ok(Json(ReindexResponse {
|
||||
message: "Links extracted successfully".to_string(),
|
||||
links_extracted: links_count,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Resolve all unresolved links in the database.
|
||||
///
|
||||
/// POST /api/v1/notes/resolve-links
|
||||
pub async fn resolve_links(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<ResolveLinksResponse>, ApiError> {
|
||||
let resolved_count = state.storage.resolve_links().await?;
|
||||
|
||||
Ok(Json(ResolveLinksResponse { resolved_count }))
|
||||
}
|
||||
|
||||
/// Get count of unresolved links.
|
||||
///
|
||||
/// GET /api/v1/notes/unresolved-count
|
||||
pub async fn get_unresolved_count(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<UnresolvedLinksResponse>, ApiError> {
|
||||
let count = state.storage.count_unresolved_links().await?;
|
||||
|
||||
Ok(Json(UnresolvedLinksResponse { count }))
|
||||
}
|
||||
|
||||
/// Create the routes for notes/links functionality.
|
||||
pub fn routes() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/graph", get(get_graph))
|
||||
.route("/resolve-links", post(resolve_links))
|
||||
.route("/unresolved-count", get(get_unresolved_count))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue