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
|
|
@ -200,6 +200,9 @@ fn row_to_media_item(row: &Row) -> Result<MediaItem> {
|
|||
|
||||
// Trash support
|
||||
deleted_at: row.try_get("deleted_at").ok().flatten(),
|
||||
|
||||
// Markdown links extraction timestamp
|
||||
links_extracted_at: row.try_get("links_extracted_at").ok().flatten(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -6036,6 +6039,425 @@ impl StorageBackend for PostgresBackend {
|
|||
let count: i64 = row.get(0);
|
||||
Ok(count as u64)
|
||||
}
|
||||
|
||||
// ===== Markdown Links (Obsidian-style) =====
|
||||
|
||||
async fn save_markdown_links(
|
||||
&self,
|
||||
media_id: MediaId,
|
||||
links: &[crate::model::MarkdownLink],
|
||||
) -> Result<()> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(format!("pool error: {e}")))?;
|
||||
|
||||
let media_id_str = media_id.0.to_string();
|
||||
|
||||
// Delete existing links for this source
|
||||
client
|
||||
.execute(
|
||||
"DELETE FROM markdown_links WHERE source_media_id = $1",
|
||||
&[&media_id_str],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
|
||||
// Insert new links
|
||||
for link in links {
|
||||
let target_media_id = link.target_media_id.map(|id| id.0.to_string());
|
||||
client
|
||||
.execute(
|
||||
"INSERT INTO markdown_links (
|
||||
id, source_media_id, target_path, target_media_id,
|
||||
link_type, link_text, line_number, context, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
&[
|
||||
&link.id.to_string(),
|
||||
&media_id_str,
|
||||
&link.target_path,
|
||||
&target_media_id,
|
||||
&link.link_type.to_string(),
|
||||
&link.link_text,
|
||||
&link.line_number,
|
||||
&link.context,
|
||||
&link.created_at,
|
||||
],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_outgoing_links(&self, media_id: MediaId) -> Result<Vec<crate::model::MarkdownLink>> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(format!("pool error: {e}")))?;
|
||||
|
||||
let media_id_str = media_id.0.to_string();
|
||||
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT id, source_media_id, target_path, target_media_id,
|
||||
link_type, link_text, line_number, context, created_at
|
||||
FROM markdown_links
|
||||
WHERE source_media_id = $1
|
||||
ORDER BY line_number",
|
||||
&[&media_id_str],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
|
||||
let mut links = Vec::new();
|
||||
for row in rows {
|
||||
links.push(row_to_markdown_link(&row)?);
|
||||
}
|
||||
|
||||
Ok(links)
|
||||
}
|
||||
|
||||
async fn get_backlinks(&self, media_id: MediaId) -> Result<Vec<crate::model::BacklinkInfo>> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(format!("pool error: {e}")))?;
|
||||
|
||||
let media_id_str = media_id.0.to_string();
|
||||
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT l.id, l.source_media_id, m.title, m.path,
|
||||
l.link_text, l.line_number, l.context, l.link_type
|
||||
FROM markdown_links l
|
||||
JOIN media_items m ON l.source_media_id = m.id
|
||||
WHERE l.target_media_id = $1
|
||||
ORDER BY m.title, l.line_number",
|
||||
&[&media_id_str],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
|
||||
let mut backlinks = Vec::new();
|
||||
for row in rows {
|
||||
let link_id_str: String = row.get(0);
|
||||
let source_id_str: String = row.get(1);
|
||||
let source_title: Option<String> = row.get(2);
|
||||
let source_path: String = row.get(3);
|
||||
let link_text: Option<String> = row.get(4);
|
||||
let line_number: Option<i32> = row.get(5);
|
||||
let context: Option<String> = row.get(6);
|
||||
let link_type_str: String = row.get(7);
|
||||
|
||||
backlinks.push(crate::model::BacklinkInfo {
|
||||
link_id: Uuid::parse_str(&link_id_str)
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?,
|
||||
source_id: MediaId(
|
||||
Uuid::parse_str(&source_id_str)
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?,
|
||||
),
|
||||
source_title,
|
||||
source_path,
|
||||
link_text,
|
||||
line_number,
|
||||
context,
|
||||
link_type: link_type_str
|
||||
.parse()
|
||||
.unwrap_or(crate::model::LinkType::Wikilink),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(backlinks)
|
||||
}
|
||||
|
||||
async fn clear_links_for_media(&self, media_id: MediaId) -> Result<()> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(format!("pool error: {e}")))?;
|
||||
|
||||
let media_id_str = media_id.0.to_string();
|
||||
|
||||
client
|
||||
.execute(
|
||||
"DELETE FROM markdown_links WHERE source_media_id = $1",
|
||||
&[&media_id_str],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_graph_data(
|
||||
&self,
|
||||
center_id: Option<MediaId>,
|
||||
depth: u32,
|
||||
) -> Result<crate::model::GraphData> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(format!("pool error: {e}")))?;
|
||||
|
||||
let depth = depth.min(5); // Limit depth
|
||||
let mut nodes = Vec::new();
|
||||
let mut edges = Vec::new();
|
||||
let mut node_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
if let Some(center) = center_id {
|
||||
// BFS to find connected nodes within depth
|
||||
let mut frontier = vec![center.0.to_string()];
|
||||
let mut visited = std::collections::HashSet::new();
|
||||
visited.insert(center.0.to_string());
|
||||
|
||||
for _ in 0..depth {
|
||||
if frontier.is_empty() {
|
||||
break;
|
||||
}
|
||||
let mut next_frontier = Vec::new();
|
||||
|
||||
for node_id in &frontier {
|
||||
// Get outgoing links
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT target_media_id FROM markdown_links
|
||||
WHERE source_media_id = $1 AND target_media_id IS NOT NULL",
|
||||
&[node_id],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
|
||||
for row in rows {
|
||||
let id: String = row.get(0);
|
||||
if !visited.contains(&id) {
|
||||
visited.insert(id.clone());
|
||||
next_frontier.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Get incoming links
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT source_media_id FROM markdown_links
|
||||
WHERE target_media_id = $1",
|
||||
&[node_id],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
|
||||
for row in rows {
|
||||
let id: String = row.get(0);
|
||||
if !visited.contains(&id) {
|
||||
visited.insert(id.clone());
|
||||
next_frontier.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
frontier = next_frontier;
|
||||
}
|
||||
|
||||
node_ids = visited;
|
||||
} else {
|
||||
// Get all markdown files with links (limit to 500)
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT DISTINCT id FROM media_items
|
||||
WHERE media_type = 'markdown' AND deleted_at IS NULL
|
||||
LIMIT 500",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
|
||||
for row in rows {
|
||||
let id: String = row.get(0);
|
||||
node_ids.insert(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Build nodes with metadata
|
||||
for node_id in &node_ids {
|
||||
let row = client
|
||||
.query_opt(
|
||||
"SELECT id, COALESCE(title, file_name) as label, title, media_type
|
||||
FROM media_items WHERE id = $1",
|
||||
&[node_id],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
|
||||
if let Some(row) = row {
|
||||
let id: String = row.get(0);
|
||||
let label: String = row.get(1);
|
||||
let title: Option<String> = row.get(2);
|
||||
let media_type: String = row.get(3);
|
||||
|
||||
// Count outgoing links
|
||||
let link_count_row = client
|
||||
.query_one(
|
||||
"SELECT COUNT(*) FROM markdown_links WHERE source_media_id = $1",
|
||||
&[&id],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
let link_count: i64 = link_count_row.get(0);
|
||||
|
||||
// Count incoming links
|
||||
let backlink_count_row = client
|
||||
.query_one(
|
||||
"SELECT COUNT(*) FROM markdown_links WHERE target_media_id = $1",
|
||||
&[&id],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
let backlink_count: i64 = backlink_count_row.get(0);
|
||||
|
||||
nodes.push(crate::model::GraphNode {
|
||||
id: id.clone(),
|
||||
label,
|
||||
title,
|
||||
media_type,
|
||||
link_count: link_count as u32,
|
||||
backlink_count: backlink_count as u32,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build edges
|
||||
for node_id in &node_ids {
|
||||
let rows = client
|
||||
.query(
|
||||
"SELECT source_media_id, target_media_id, link_type
|
||||
FROM markdown_links
|
||||
WHERE source_media_id = $1 AND target_media_id IS NOT NULL",
|
||||
&[node_id],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
|
||||
for row in rows {
|
||||
let source: String = row.get(0);
|
||||
let target: String = row.get(1);
|
||||
let link_type_str: String = row.get(2);
|
||||
|
||||
if node_ids.contains(&target) {
|
||||
edges.push(crate::model::GraphEdge {
|
||||
source,
|
||||
target,
|
||||
link_type: link_type_str
|
||||
.parse()
|
||||
.unwrap_or(crate::model::LinkType::Wikilink),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(crate::model::GraphData { nodes, edges })
|
||||
}
|
||||
|
||||
async fn resolve_links(&self) -> Result<u64> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(format!("pool error: {e}")))?;
|
||||
|
||||
// Strategy 1: Exact path match
|
||||
let result1 = client
|
||||
.execute(
|
||||
"UPDATE markdown_links
|
||||
SET target_media_id = (
|
||||
SELECT id FROM media_items
|
||||
WHERE path = markdown_links.target_path
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE target_media_id IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM media_items
|
||||
WHERE path = markdown_links.target_path
|
||||
AND deleted_at IS NULL
|
||||
)",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
|
||||
// Strategy 2: Filename match
|
||||
let result2 = client
|
||||
.execute(
|
||||
"UPDATE markdown_links
|
||||
SET target_media_id = (
|
||||
SELECT id FROM media_items
|
||||
WHERE (file_name = markdown_links.target_path
|
||||
OR file_name = markdown_links.target_path || '.md'
|
||||
OR REPLACE(file_name, '.md', '') = markdown_links.target_path)
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE target_media_id IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM media_items
|
||||
WHERE (file_name = markdown_links.target_path
|
||||
OR file_name = markdown_links.target_path || '.md'
|
||||
OR REPLACE(file_name, '.md', '') = markdown_links.target_path)
|
||||
AND deleted_at IS NULL
|
||||
)",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
|
||||
Ok(result1 + result2)
|
||||
}
|
||||
|
||||
async fn mark_links_extracted(&self, media_id: MediaId) -> Result<()> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(format!("pool error: {e}")))?;
|
||||
|
||||
let media_id_str = media_id.0.to_string();
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
client
|
||||
.execute(
|
||||
"UPDATE media_items SET links_extracted_at = $1 WHERE id = $2",
|
||||
&[&now, &media_id_str],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn count_unresolved_links(&self) -> Result<u64> {
|
||||
let client = self
|
||||
.pool
|
||||
.get()
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(format!("pool error: {e}")))?;
|
||||
|
||||
let row = client
|
||||
.query_one(
|
||||
"SELECT COUNT(*) FROM markdown_links WHERE target_media_id IS NULL",
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| PinakesError::Database(e.to_string()))?;
|
||||
|
||||
let count: i64 = row.get(0);
|
||||
Ok(count as u64)
|
||||
}
|
||||
}
|
||||
|
||||
impl PostgresBackend {
|
||||
|
|
@ -6329,6 +6751,37 @@ fn find_first_fts_param(query: &SearchQuery) -> i32 {
|
|||
find_inner(query, &mut offset).unwrap_or(1)
|
||||
}
|
||||
|
||||
// Helper function to parse a markdown link row
|
||||
fn row_to_markdown_link(row: &Row) -> Result<crate::model::MarkdownLink> {
|
||||
let id_str: String = row.get(0);
|
||||
let source_id_str: String = row.get(1);
|
||||
let target_path: String = row.get(2);
|
||||
let target_id: Option<String> = row.get(3);
|
||||
let link_type_str: String = row.get(4);
|
||||
let link_text: Option<String> = row.get(5);
|
||||
let line_number: Option<i32> = row.get(6);
|
||||
let context: Option<String> = row.get(7);
|
||||
let created_at: chrono::DateTime<Utc> = row.get(8);
|
||||
|
||||
Ok(crate::model::MarkdownLink {
|
||||
id: Uuid::parse_str(&id_str).map_err(|e| PinakesError::Database(e.to_string()))?,
|
||||
source_media_id: MediaId(
|
||||
Uuid::parse_str(&source_id_str).map_err(|e| PinakesError::Database(e.to_string()))?,
|
||||
),
|
||||
target_path,
|
||||
target_media_id: target_id
|
||||
.and_then(|s| Uuid::parse_str(&s).ok())
|
||||
.map(MediaId),
|
||||
link_type: link_type_str
|
||||
.parse()
|
||||
.unwrap_or(crate::model::LinkType::Wikilink),
|
||||
link_text,
|
||||
line_number,
|
||||
context,
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue