use crate::error::Result;
use crate::frontmatter::{extract_tags, parse_frontmatter};
use crate::project::MDDBProject;
use rusqlite::{Connection, params};
use serde::Serialize;
use std::path::Path;
pub fn escape_fts5_query(query: &str) -> String {
format!("\"{}\"", query.replace('"', "\"\""))
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct SearchResult {
pub path: String,
pub snippet: String,
pub score: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Link {
pub target: String,
pub raw_target: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ReachableNode {
pub path: String,
pub depth: i64,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct DocumentContent {
pub path: String,
pub content: String,
pub tags: Vec<String>,
}
fn query_links(conn: &Connection, sql: &str, id: &str) -> Result<Vec<Link>> {
let mut stmt = conn.prepare_cached(sql)?;
stmt.query_map(params![id], |row| {
Ok(Link {
target: row.get(0)?,
raw_target: row.get(1)?,
})
})?
.map(|r| r.map_err(Into::into))
.collect()
}
impl MDDBProject {
pub fn search(
&self,
query: &str,
limit: usize,
path_filter: Option<&str>,
snippet_length: Option<i64>,
) -> Result<Vec<SearchResult>> {
let conn = self.get_conn();
let path_like = match path_filter {
Some(prefix) => format!("{}%", prefix),
None => "%".to_string(),
};
let snippet_len = snippet_length.unwrap_or(32);
let limit_i64 = limit as i64;
let mut stmt = conn.prepare_cached(
"SELECT path, snippet, score
FROM (
SELECT path, snippet, score,
ROW_NUMBER() OVER (
PARTITION BY path
ORDER BY score, source_priority
) as rn
FROM (
SELECT path,
snippet(documents_fts, 1, '<b>', '</b>', ' ... ', ?4) as snippet,
bm25(documents_fts) as score,
1 as source_priority
FROM documents_fts
WHERE documents_fts MATCH ?1 AND path LIKE ?3
UNION ALL
SELECT path,
headings as snippet,
bm25(headings_fts, 20.0) as score,
2 as source_priority
FROM headings_fts
WHERE headings_fts MATCH ?1 AND path LIKE ?3
UNION ALL
SELECT path,
tags as snippet,
bm25(tags_fts, 10.0) as score,
3 as source_priority
FROM tags_fts
WHERE tags_fts MATCH ?1 AND path LIKE ?3
)
)
WHERE rn = 1
ORDER BY score
LIMIT ?2",
)?;
stmt.query_map(params![query, limit_i64, path_like, snippet_len], |row| {
Ok(SearchResult {
path: row.get(0)?,
snippet: row.get(1)?,
score: row.get(2)?,
})
})?
.map(|r| r.map_err(Into::into))
.collect::<Result<Vec<_>>>()
}
pub fn get_links_from(&self, from_id: &str) -> Result<Vec<Link>> {
query_links(
self.get_conn(),
"SELECT to_id, raw_target FROM links WHERE from_id = ?1",
from_id,
)
}
pub fn get_citations_from(&self, from_id: &str) -> Result<Vec<String>> {
let conn = self.get_conn();
let mut stmt = conn.prepare_cached("SELECT url FROM citations WHERE from_id = ?1")?;
stmt.query_map(params![from_id], |row| row.get(0))?
.map(|r| r.map_err(Into::into))
.collect()
}
pub fn get_links_to(&self, to_id: &str) -> Result<Vec<Link>> {
query_links(
self.get_conn(),
"SELECT from_id, raw_target FROM links WHERE to_id = ?1",
to_id,
)
}
pub fn get_reachable(&self, from_id: &str, max_depth: i64) -> Result<Vec<ReachableNode>> {
let conn = self.get_conn();
let mut stmt = conn.prepare_cached(
"WITH RECURSIVE bfs AS (
SELECT to_id AS node, 1 AS depth
FROM links
WHERE from_id = ?1
UNION ALL
SELECT l.to_id, bfs.depth + 1
FROM links l
JOIN bfs ON l.from_id = bfs.node
WHERE bfs.depth < ?2
)
SELECT node, MIN(depth) AS depth
FROM bfs
GROUP BY node
ORDER BY depth",
)?;
stmt.query_map(params![from_id, max_depth], |row| {
Ok(ReachableNode {
path: row.get(0)?,
depth: row.get(1)?,
})
})?
.map(|r| r.map_err(Into::into))
.collect::<Result<Vec<_>>>()
}
pub fn read_document(&self, path: &str) -> Result<DocumentContent> {
let full_path = Path::new(self.get_root()).join(path);
let content = std::fs::read_to_string(&full_path)?;
let tags = parse_frontmatter(&content)
.map(|fm| extract_tags(&fm))
.unwrap_or_default();
Ok(DocumentContent {
path: path.to_string(),
content,
tags,
})
}
}