various: markdown improvements

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I81fda8247814da19eed1e76dbe97bd5b6a6a6964
This commit is contained in:
raf 2026-02-05 15:39:05 +03:00
commit 80a8b5c7ca
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
23 changed files with 3458 additions and 30 deletions

View file

@ -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;

View 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))
}