1use crate::error::Result;
2use crate::frontmatter::{extract_tags, parse_frontmatter};
3use crate::project::MDDBProject;
4use rusqlite::{Connection, params};
5use serde::Serialize;
6use std::path::Path;
7
8pub fn escape_fts5_query(query: &str) -> String {
11 format!("\"{}\"", query.replace('"', "\"\""))
12}
13
14#[derive(Debug, Clone, PartialEq, Serialize)]
15pub struct SearchResult {
16 pub path: String,
17 pub snippet: String,
18 pub score: f64,
19}
20
21#[derive(Debug, Clone, PartialEq, Serialize)]
22pub struct Link {
23 pub target: String,
24 pub raw_target: Option<String>,
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize)]
28pub struct ReachableNode {
29 pub path: String,
30 pub depth: i64,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize)]
34pub struct DocumentContent {
35 pub path: String,
36 pub content: String,
37 pub tags: Vec<String>,
38}
39
40fn query_links(conn: &Connection, sql: &str, id: &str) -> Result<Vec<Link>> {
41 let mut stmt = conn.prepare_cached(sql)?;
42 stmt.query_map(params![id], |row| {
43 Ok(Link {
44 target: row.get(0)?,
45 raw_target: row.get(1)?,
46 })
47 })?
48 .map(|r| r.map_err(Into::into))
49 .collect()
50}
51
52impl MDDBProject {
53 pub fn search(
60 &self,
61 query: &str,
62 limit: usize,
63 path_filter: Option<&str>,
64 snippet_length: Option<i64>,
65 ) -> Result<Vec<SearchResult>> {
66 let conn = self.get_conn();
67 let path_like = match path_filter {
68 Some(prefix) => format!("{}%", prefix),
69 None => "%".to_string(),
70 };
71 let snippet_len = snippet_length.unwrap_or(32);
72 let limit_i64 = limit as i64;
73 let mut stmt = conn.prepare_cached(
74 "SELECT path, snippet, score
75 FROM (
76 SELECT path, snippet, score,
77 ROW_NUMBER() OVER (
78 PARTITION BY path
79 ORDER BY score, source_priority
80 ) as rn
81 FROM (
82 SELECT path,
83 snippet(documents_fts, 1, '<b>', '</b>', ' ... ', ?4) as snippet,
84 bm25(documents_fts) as score,
85 1 as source_priority
86 FROM documents_fts
87 WHERE documents_fts MATCH ?1 AND path LIKE ?3
88 UNION ALL
89 SELECT path,
90 headings as snippet,
91 bm25(headings_fts, 20.0) as score,
92 2 as source_priority
93 FROM headings_fts
94 WHERE headings_fts MATCH ?1 AND path LIKE ?3
95 UNION ALL
96 SELECT path,
97 tags as snippet,
98 bm25(tags_fts, 10.0) as score,
99 3 as source_priority
100 FROM tags_fts
101 WHERE tags_fts MATCH ?1 AND path LIKE ?3
102 )
103 )
104 WHERE rn = 1
105 ORDER BY score
106 LIMIT ?2",
107 )?;
108
109 stmt.query_map(params![query, limit_i64, path_like, snippet_len], |row| {
110 Ok(SearchResult {
111 path: row.get(0)?,
112 snippet: row.get(1)?,
113 score: row.get(2)?,
114 })
115 })?
116 .map(|r| r.map_err(Into::into))
117 .collect::<Result<Vec<_>>>()
118 }
119
120 pub fn get_links_from(&self, from_id: &str) -> Result<Vec<Link>> {
123 query_links(
124 self.get_conn(),
125 "SELECT to_id, raw_target FROM links WHERE from_id = ?1",
126 from_id,
127 )
128 }
129
130 pub fn get_citations_from(&self, from_id: &str) -> Result<Vec<String>> {
132 let conn = self.get_conn();
133 let mut stmt = conn.prepare_cached("SELECT url FROM citations WHERE from_id = ?1")?;
134 stmt.query_map(params![from_id], |row| row.get(0))?
135 .map(|r| r.map_err(Into::into))
136 .collect()
137 }
138
139 pub fn get_links_to(&self, to_id: &str) -> Result<Vec<Link>> {
141 query_links(
142 self.get_conn(),
143 "SELECT from_id, raw_target FROM links WHERE to_id = ?1",
144 to_id,
145 )
146 }
147
148 pub fn get_reachable(&self, from_id: &str, max_depth: i64) -> Result<Vec<ReachableNode>> {
151 let conn = self.get_conn();
152 let mut stmt = conn.prepare_cached(
153 "WITH RECURSIVE bfs AS (
154 SELECT to_id AS node, 1 AS depth
155 FROM links
156 WHERE from_id = ?1
157 UNION ALL
158 SELECT l.to_id, bfs.depth + 1
159 FROM links l
160 JOIN bfs ON l.from_id = bfs.node
161 WHERE bfs.depth < ?2
162 )
163 SELECT node, MIN(depth) AS depth
164 FROM bfs
165 GROUP BY node
166 ORDER BY depth",
167 )?;
168
169 stmt.query_map(params![from_id, max_depth], |row| {
170 Ok(ReachableNode {
171 path: row.get(0)?,
172 depth: row.get(1)?,
173 })
174 })?
175 .map(|r| r.map_err(Into::into))
176 .collect::<Result<Vec<_>>>()
177 }
178
179 pub fn read_document(&self, path: &str) -> Result<DocumentContent> {
182 let full_path = Path::new(self.get_root()).join(path);
183 let content = std::fs::read_to_string(&full_path)?;
184 let tags = parse_frontmatter(&content)
185 .map(|fm| extract_tags(&fm))
186 .unwrap_or_default();
187
188 Ok(DocumentContent {
189 path: path.to_string(),
190 content,
191 tags,
192 })
193 }
194}