1mod freshness;
17mod plan;
18mod relative;
19mod schema;
20mod sync;
21
22use crate::error::Result;
23use crate::paths::MailPaths;
24use ecr_core::message::{Message, Query, ThreadId, ThreadSummary};
25use ecr_core::revision::Revision;
26use rusqlite::{Connection, OptionalExtension};
27use std::collections::{BTreeMap, BTreeSet};
28use std::path::{Path, PathBuf};
29use std::sync::Mutex;
30
31pub use freshness::Freshness;
32pub use sync::{refresh, refresh_incremental, Refreshed};
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct IndexStatus {
37 pub path: Option<PathBuf>,
38 pub revision: Option<Revision>,
39 pub messages: u64,
40 pub bytes: u64,
41}
42
43pub struct MessageIndex {
44 conn: Mutex<Connection>,
45 path: Option<PathBuf>,
46 exclude_tags: Vec<String>,
47}
48
49impl MessageIndex {
50 pub fn file_name() -> &'static str {
51 "index.sqlite3"
52 }
53
54 pub fn path_for(paths: &MailPaths) -> PathBuf {
55 paths.ecr_state_dir.join(Self::file_name())
56 }
57
58 pub fn open(paths: &MailPaths) -> Result<Self> {
59 let path = Self::path_for(paths);
60 if let Some(parent) = path.parent() {
61 std::fs::create_dir_all(parent)?;
62 }
63 Self::open_at(&path, paths.notmuch_config.exclude_tags.clone())
64 }
65
66 pub fn open_at(path: &Path, exclude_tags: Vec<String>) -> Result<Self> {
67 let conn = Connection::open(path)?;
68 schema::prepare(&conn)?;
69
70 Ok(Self {
71 conn: Mutex::new(conn),
72 path: Some(path.to_path_buf()),
73 exclude_tags,
74 })
75 }
76
77 pub fn in_memory(exclude_tags: Vec<String>) -> Result<Self> {
78 let conn = Connection::open_in_memory()?;
79 schema::prepare(&conn)?;
80
81 Ok(Self {
82 conn: Mutex::new(conn),
83 path: None,
84 exclude_tags,
85 })
86 }
87
88 fn with<T>(&self, f: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
89 let conn = self
90 .conn
91 .lock()
92 .map_err(|_| crate::Error::Index("the index lock was poisoned".into()))?;
93 f(&conn)
94 }
95
96 pub fn revision(&self) -> Result<Option<Revision>> {
97 self.with(|conn| {
98 let uuid: Option<String> = meta(conn, "uuid")?;
99 let lastmod: Option<String> = meta(conn, "lastmod")?;
100 Ok(match (uuid, lastmod.and_then(|v| v.parse().ok())) {
101 (Some(uuid), Some(lastmod)) => Some(Revision { uuid, lastmod }),
102 _ => None,
103 })
104 })
105 }
106
107 pub fn message_count(&self) -> Result<u64> {
108 self.with(|conn| {
109 let count: i64 =
110 conn.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?;
111 Ok(count as u64)
112 })
113 }
114
115 pub fn status(&self) -> IndexStatus {
116 IndexStatus {
117 path: self.path.clone(),
118 revision: self.revision().ok().flatten(),
119 messages: self.message_count().unwrap_or(0),
120 bytes: self
121 .path
122 .as_ref()
123 .and_then(|p| std::fs::metadata(p).ok())
124 .map(|m| m.len())
125 .unwrap_or(0),
126 }
127 }
128
129 pub fn search_threads(&self, query: &Query) -> Result<Option<Vec<ThreadSummary>>> {
131 let Some(plan) = plan::plan(query.effective_text(), &self.exclude_tags) else {
132 return Ok(None);
133 };
134
135 self.with(|conn| {
136 let page = thread_page(conn, &plan, query.limit, query.offset)?;
137 if page.is_empty() {
138 return Ok(Some(Vec::new()));
139 }
140 Ok(Some(summaries(conn, &plan, page)?))
141 })
142 }
143
144 pub fn count(&self, text: &str) -> Result<Option<u64>> {
145 let Some(plan) = plan::plan(text, &self.exclude_tags) else {
146 return Ok(None);
147 };
148
149 self.with(|conn| {
150 let sql = format!(
151 "SELECT COUNT(*) FROM messages m WHERE {}",
152 plan.predicate(plan::Shape::Set)
153 );
154 let count: i64 =
155 conn.query_row(&sql, rusqlite::params_from_iter(&plan.params), |row| {
156 row.get(0)
157 })?;
158 Ok(Some(count as u64))
159 })
160 }
161
162 pub fn apply(&self, messages: &[Message], revision: &Revision) -> Result<()> {
166 self.with(|conn| {
167 let tx = conn.unchecked_transaction()?;
168 for message in messages {
169 upsert(&tx, message)?;
170 }
171 set_meta(&tx, "uuid", &revision.uuid)?;
172 set_meta(&tx, "lastmod", &revision.lastmod.to_string())?;
173 tx.commit()?;
174 Ok(())
175 })
176 }
177
178 pub fn clear(&self) -> Result<()> {
179 self.with(schema::reset)
180 }
181}
182
183fn meta(conn: &Connection, key: &str) -> Result<Option<String>> {
184 Ok(conn
185 .query_row("SELECT value FROM meta WHERE key = ?", [key], |row| {
186 row.get(0)
187 })
188 .optional()?)
189}
190
191fn set_meta(conn: &Connection, key: &str, value: &str) -> Result<()> {
192 conn.execute(
193 "INSERT INTO meta (key, value) VALUES (?, ?)
194 ON CONFLICT (key) DO UPDATE SET value = excluded.value",
195 [key, value],
196 )?;
197 Ok(())
198}
199
200fn upsert(conn: &Connection, message: &Message) -> Result<()> {
201 let author = message
202 .from
203 .first()
204 .map(|a| a.display().to_string())
205 .unwrap_or_default();
206
207 let num: i64 = conn.query_row(
208 "INSERT INTO messages (id, thread, timestamp, subject, author)
209 VALUES (?, ?, ?, ?, ?)
210 ON CONFLICT (id) DO UPDATE SET
211 thread = excluded.thread,
212 timestamp = excluded.timestamp,
213 subject = excluded.subject,
214 author = excluded.author
215 RETURNING num",
216 rusqlite::params![
217 message.id.as_str(),
218 message.thread_id.as_str(),
219 message.timestamp,
220 message.subject,
221 author,
222 ],
223 |row| row.get(0),
224 )?;
225
226 conn.execute("DELETE FROM tags WHERE message = ?", [num])?;
227 let mut insert = conn.prepare_cached("INSERT INTO tags (message, tag) VALUES (?, ?)")?;
228 for tag in &message.tags {
229 insert.execute(rusqlite::params![num, tag])?;
230 }
231
232 Ok(())
233}
234
235struct Page {
236 thread: String,
237 timestamp: i64,
238}
239
240fn thread_page(
254 conn: &Connection,
255 plan: &plan::Plan,
256 limit: usize,
257 offset: usize,
258) -> Result<Vec<Page>> {
259 let wanted = offset.saturating_add(limit);
260 if wanted == 0 {
261 return Ok(Vec::new());
262 }
263
264 let sql = format!(
265 "SELECT m.thread, m.timestamp
266 FROM messages m
267 WHERE {}
268 ORDER BY m.timestamp DESC",
269 plan.predicate(plan::Shape::Scan)
270 );
271
272 let mut statement = conn.prepare(&sql)?;
273 let mut rows = statement.query(rusqlite::params_from_iter(&plan.params))?;
274
275 let mut seen: BTreeSet<String> = BTreeSet::new();
276 let mut found: Vec<Page> = Vec::new();
277
278 while let Some(row) = rows.next()? {
279 let timestamp: i64 = row.get(1)?;
280
281 if found.len() >= wanted && found.last().is_some_and(|p| p.timestamp != timestamp) {
288 break;
289 }
290
291 let thread: String = row.get(0)?;
292 if seen.insert(thread.clone()) {
293 found.push(Page { thread, timestamp });
294 }
295 }
296
297 found.sort_by(|a, b| b.timestamp.cmp(&a.timestamp).then(a.thread.cmp(&b.thread)));
298
299 Ok(found.into_iter().skip(offset).take(limit).collect())
300}
301
302struct Row {
303 thread: String,
304 id: String,
305 subject: String,
306 author: String,
307 matched: bool,
308}
309
310fn thread_subject(newest: &str) -> String {
329 match newest.get(..4) {
330 Some(head) if head.eq_ignore_ascii_case("re: ") => newest[4..].to_string(),
331 _ => newest.to_string(),
332 }
333}
334
335fn summaries(conn: &Connection, plan: &plan::Plan, page: Vec<Page>) -> Result<Vec<ThreadSummary>> {
336 let threads: Vec<&str> = page.iter().map(|p| p.thread.as_str()).collect();
337 let holes = vec!["?"; threads.len()].join(", ");
338
339 let sql = format!(
347 "SELECT m.thread, m.id, m.subject, m.author, ({})
348 FROM messages m
349 WHERE m.thread IN ({holes})
350 ORDER BY m.thread, m.timestamp, m.num",
351 plan.predicate(plan::Shape::Scan)
352 );
353
354 let params = plan
355 .params
356 .iter()
357 .map(|p| p.as_str())
358 .chain(threads.iter().copied())
359 .collect::<Vec<&str>>();
360
361 let mut statement = conn.prepare(&sql)?;
362 let rows = statement
363 .query_map(rusqlite::params_from_iter(params), |row| {
364 Ok(Row {
365 thread: row.get(0)?,
366 id: row.get(1)?,
367 subject: row.get(2)?,
368 author: row.get(3)?,
369 matched: row.get(4)?,
370 })
371 })?
372 .collect::<rusqlite::Result<Vec<_>>>()?;
373
374 let mut by_thread: BTreeMap<String, Vec<Row>> = BTreeMap::new();
375 for row in rows {
376 by_thread.entry(row.thread.clone()).or_default().push(row);
377 }
378
379 let tags = thread_tags(conn, &threads, &holes)?;
380 let now = relative::now();
381
382 Ok(page
383 .into_iter()
384 .map(|entry| {
385 let rows = by_thread.remove(&entry.thread).unwrap_or_default();
386 let (matched, unmatched): (Vec<&Row>, Vec<&Row>) =
387 rows.iter().partition(|row| row.matched);
388
389 ThreadSummary {
390 id: ThreadId(entry.thread.clone()),
391 subject: matched
392 .last()
393 .map(|r| thread_subject(&r.subject))
394 .unwrap_or_default(),
395 authors: authors(&matched, &unmatched),
396 timestamp: entry.timestamp,
397 date_relative: relative::describe(entry.timestamp, now),
398 matched: matched.len(),
399 total: rows.len(),
400 tags: tags.get(&entry.thread).cloned().unwrap_or_default(),
401 newest_message: matched.last().map(|row| row.id.as_str().into()),
402 }
403 })
404 .collect())
405}
406
407fn thread_tags(
411 conn: &Connection,
412 threads: &[&str],
413 holes: &str,
414) -> Result<BTreeMap<String, BTreeSet<String>>> {
415 let sql = format!(
416 "SELECT m.thread, t.tag
417 FROM messages m JOIN tags t ON t.message = m.num
418 WHERE m.thread IN ({holes})"
419 );
420
421 let mut statement = conn.prepare(&sql)?;
422 let rows = statement.query_map(rusqlite::params_from_iter(threads), |row| {
423 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
424 })?;
425
426 let mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
427 for row in rows {
428 let (thread, tag) = row?;
429 out.entry(thread).or_default().insert(tag);
430 }
431 Ok(out)
432}
433
434fn authors(matched: &[&Row], unmatched: &[&Row]) -> Vec<String> {
443 fn join(rows: &[&Row], seen: &mut BTreeSet<String>) -> String {
444 rows.iter()
445 .filter(|row| !row.author.is_empty() && seen.insert(row.author.clone()))
446 .map(|row| row.author.as_str())
447 .collect::<Vec<_>>()
448 .join(", ")
449 }
450
451 let mut seen = BTreeSet::new();
452 let rendered = format!(
453 "{}|{}",
454 join(matched, &mut seen),
455 join(unmatched, &mut seen)
456 );
457
458 crate::notmuch::split_authors(&rendered)
459}
460
461#[cfg(test)]
462mod tests;