1use boarddown_core::{TaskId, BoardId, Error, SearchResult};
2use rusqlite::Connection;
3
4#[derive(Debug)]
5pub struct FullTextSearch {
6 enabled: bool,
7}
8
9impl FullTextSearch {
10 pub fn new(enabled: bool) -> Self {
11 Self { enabled }
12 }
13
14 pub fn enabled(&self) -> bool {
15 self.enabled
16 }
17
18 pub fn init(&self, conn: &Connection) -> Result<(), Error> {
19 if !self.enabled {
20 return Ok(());
21 }
22
23 conn.execute(
24 "CREATE VIRTUAL TABLE IF NOT EXISTS tasks_fts USING fts5(
25 board_id,
26 id,
27 title,
28 description,
29 tags
30 )",
31 [],
32 ).map_err(|e| Error::Database(e.to_string()))?;
33
34 Ok(())
35 }
36
37 pub fn index_task(&self, conn: &Connection, board_id: &BoardId, task_id: &TaskId, title: &str, description: &str, tags: &[String]) -> Result<(), Error> {
38 if !self.enabled {
39 return Ok(());
40 }
41
42 let tags_str = tags.join(" ");
43
44 conn.execute(
45 "INSERT OR REPLACE INTO tasks_fts (board_id, id, title, description, tags)
46 VALUES (?1, ?2, ?3, ?4, ?5)",
47 rusqlite::params![
48 board_id.as_ref(),
49 task_id.to_string(),
50 title,
51 description,
52 tags_str
53 ],
54 ).map_err(|e| Error::Database(e.to_string()))?;
55
56 Ok(())
57 }
58
59 pub fn remove_task(&self, conn: &Connection, board_id: &BoardId, task_id: &TaskId) -> Result<(), Error> {
60 if !self.enabled {
61 return Ok(());
62 }
63
64 conn.execute(
65 "DELETE FROM tasks_fts WHERE board_id = ?1 AND id = ?2",
66 rusqlite::params![board_id.as_ref(), task_id.to_string()],
67 ).map_err(|e| Error::Database(e.to_string()))?;
68
69 Ok(())
70 }
71
72 pub fn search(&self, conn: &Connection, query: &str) -> Result<Vec<SearchResult>, Error> {
73 if !self.enabled {
74 return Ok(Vec::new());
75 }
76
77 let mut stmt = conn.prepare(
78 "SELECT id, title, snippet(tasks_fts, 2, '<mark>', '</mark>', '...', 32) as snippet, rank
79 FROM tasks_fts WHERE tasks_fts MATCH ? ORDER BY rank"
80 ).map_err(|e| Error::Database(e.to_string()))?;
81
82 let rows = stmt.query_map([query], |row| {
83 Ok(SearchResult {
84 task_id: TaskId::parse(&row.get::<_, String>(0)?).unwrap_or_else(|_| TaskId::new("TASK", 0)),
85 title: row.get(1)?,
86 snippet: row.get(2)?,
87 rank: row.get(3)?,
88 })
89 }).map_err(|e| Error::Database(e.to_string()))?;
90
91 let mut results = Vec::new();
92 for row in rows {
93 results.push(row.map_err(|e| Error::Database(e.to_string()))?);
94 }
95
96 Ok(results)
97 }
98
99 pub fn search_in_board(&self, conn: &Connection, board_id: &BoardId, query: &str) -> Result<Vec<SearchResult>, Error> {
100 if !self.enabled {
101 return Ok(Vec::new());
102 }
103
104 let mut stmt = conn.prepare(
105 "SELECT id, title, snippet(tasks_fts, 2, '<mark>', '</mark>', '...', 32) as snippet, rank
106 FROM tasks_fts WHERE board_id = ?1 AND tasks_fts MATCH ?2 ORDER BY rank"
107 ).map_err(|e| Error::Database(e.to_string()))?;
108
109 let rows = stmt.query_map(rusqlite::params![board_id.as_ref(), query], |row| {
110 Ok(SearchResult {
111 task_id: TaskId::parse(&row.get::<_, String>(0)?).unwrap_or_else(|_| TaskId::new("TASK", 0)),
112 title: row.get(1)?,
113 snippet: row.get(2)?,
114 rank: row.get(3)?,
115 })
116 }).map_err(|e| Error::Database(e.to_string()))?;
117
118 let mut results = Vec::new();
119 for row in rows {
120 results.push(row.map_err(|e| Error::Database(e.to_string()))?);
121 }
122
123 Ok(results)
124 }
125}