1use std::path::PathBuf;
2
3use anyhow::{Result, anyhow, bail};
4use rusqlite::{Connection, OpenFlags, OptionalExtension};
5
6#[derive(Debug)]
7pub struct NoteRecord {
8 pub text: String,
9}
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct NoteListItem {
13 pub identifier: String,
14 pub title: String,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct SearchResult {
19 pub identifier: String,
20 pub title: String,
21 pub snippet: Option<String>,
22 pub modified_at: Option<i64>,
23 pub rank: u8,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ExportNote {
28 pub identifier: String,
29 pub title: String,
30 pub text: String,
31 pub pinned: bool,
32 pub created_at: Option<i64>,
33 pub modified_at: Option<i64>,
34 pub tags: Vec<String>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct DuplicateNote {
39 pub identifier: String,
40 pub modified_at: Option<String>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct DuplicateGroup {
45 pub title: String,
46 pub notes: Vec<DuplicateNote>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct StatsSummary {
51 pub total_notes: usize,
52 pub pinned_notes: usize,
53 pub tagged_notes: usize,
54 pub archived_notes: usize,
55 pub trashed_notes: usize,
56 pub unique_tags: usize,
57 pub total_words: usize,
58 pub notes_with_todos: usize,
59 pub oldest_modified: Option<i64>,
60 pub newest_modified: Option<i64>,
61 pub top_tags: Vec<(String, usize)>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct HealthNoteIssue {
66 pub identifier: String,
67 pub title: String,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct LargeNoteIssue {
72 pub identifier: String,
73 pub title: String,
74 pub size_bytes: usize,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct HealthSummary {
79 pub total_notes: usize,
80 pub duplicate_groups: usize,
81 pub duplicate_notes: usize,
82 pub empty_notes: Vec<HealthNoteIssue>,
83 pub untagged_notes: usize,
84 pub old_trashed_notes: Vec<HealthNoteIssue>,
85 pub large_notes: Vec<LargeNoteIssue>,
86 pub conflict_notes: Vec<HealthNoteIssue>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct NoteFile {
92 pub filename: String,
94 pub file_uuid: String,
97}
98
99pub struct BearDb {
100 connection: Connection,
101}
102
103impl BearDb {
104 pub fn open(path: PathBuf) -> Result<Self> {
105 let connection = Connection::open_with_flags(
106 &path,
107 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
108 )
109 .map_err(|err| anyhow!("failed to open Bear database at {}: {err}", path.display()))?;
110 Ok(Self { connection })
111 }
112
113 #[cfg(test)]
114 fn from_connection(connection: Connection) -> Self {
115 Self { connection }
116 }
117
118 pub fn find_note(
119 &self,
120 id: Option<&str>,
121 title: Option<&str>,
122 exclude_trashed: bool,
123 ) -> Result<NoteRecord> {
124 if id.is_none() && title.is_none() {
125 bail!("provide either --id or --title");
126 }
127
128 let sql = if id.is_some() {
129 if exclude_trashed {
130 "select ZUNIQUEIDENTIFIER, coalesce(ZTITLE, ''), coalesce(ZTEXT, '')
131 from ZSFNOTE
132 where ZUNIQUEIDENTIFIER = ?1 and ZTRASHED = 0
133 limit 1"
134 } else {
135 "select ZUNIQUEIDENTIFIER, coalesce(ZTITLE, ''), coalesce(ZTEXT, '')
136 from ZSFNOTE
137 where ZUNIQUEIDENTIFIER = ?1
138 limit 1"
139 }
140 } else if exclude_trashed {
141 "select ZUNIQUEIDENTIFIER, coalesce(ZTITLE, ''), coalesce(ZTEXT, '')
142 from ZSFNOTE
143 where ZTITLE = ?1 and ZTRASHED = 0
144 order by ZMODIFICATIONDATE desc
145 limit 1"
146 } else {
147 "select ZUNIQUEIDENTIFIER, coalesce(ZTITLE, ''), coalesce(ZTEXT, '')
148 from ZSFNOTE
149 where ZTITLE = ?1
150 order by ZMODIFICATIONDATE desc
151 limit 1"
152 };
153
154 let needle = id.or(title).unwrap_or_default();
155 self.connection
156 .query_row(sql, [needle], |row| Ok(NoteRecord { text: row.get(2)? }))
157 .optional()?
158 .ok_or_else(|| anyhow!("note not found"))
159 }
160
161 pub fn tags(&self) -> Result<Vec<String>> {
162 let mut stmt = self.connection.prepare(
163 "select ZTITLE
164 from ZSFNOTETAG
165 where ZTITLE is not null and ZENCRYPTED = 0
166 order by lower(ZTITLE) asc",
167 )?;
168 let rows = stmt.query_map([], |row| row.get(0))?;
169 rows.collect::<std::result::Result<Vec<_>, _>>()
170 .map_err(Into::into)
171 }
172
173 pub fn notes_for_tags(
174 &self,
175 tags: &[String],
176 include_trashed: bool,
177 ) -> Result<Vec<NoteListItem>> {
178 if tags.is_empty() {
179 bail!("at least one tag is required");
180 }
181
182 let placeholders = (0..tags.len()).map(|_| "?").collect::<Vec<_>>().join(", ");
183 let trashed_filter = if include_trashed {
184 ""
185 } else {
186 "and n.ZTRASHED = 0"
187 };
188 let sql = format!(
189 "select distinct n.ZUNIQUEIDENTIFIER, coalesce(n.ZTITLE, '')
190 from ZSFNOTE n
191 join Z_5TAGS nt on nt.Z_5NOTES = n.Z_PK
192 join ZSFNOTETAG t on t.Z_PK = nt.Z_13TAGS
193 where t.ZTITLE in ({placeholders})
194 and n.ZENCRYPTED = 0
195 and n.ZLOCKED = 0
196 and n.ZPERMANENTLYDELETED = 0
197 {trashed_filter}
198 order by lower(coalesce(n.ZTITLE, '')) asc"
199 );
200 let mut stmt = self.connection.prepare(&sql)?;
201 let rows = stmt.query_map(rusqlite::params_from_iter(tags.iter()), |row| {
202 Ok(NoteListItem {
203 identifier: row.get(0)?,
204 title: row.get(1)?,
205 })
206 })?;
207 rows.collect::<std::result::Result<Vec<_>, _>>()
208 .map_err(Into::into)
209 }
210
211 pub fn search(
212 &self,
213 term: Option<&str>,
214 tag: Option<&str>,
215 include_trashed: bool,
216 since: Option<i64>,
217 before: Option<i64>,
218 ) -> Result<Vec<SearchResult>> {
219 let term = term.unwrap_or_default().trim().to_lowercase();
220 let tag_filter = tag.map(str::trim).filter(|value| !value.is_empty());
221 let sql = if include_trashed {
222 "select ZUNIQUEIDENTIFIER, coalesce(ZTITLE, ''), coalesce(ZTEXT, ''), ZMODIFICATIONDATE
223 from ZSFNOTE
224 where ZENCRYPTED = 0
225 and ZLOCKED = 0
226 and ZPERMANENTLYDELETED = 0"
227 } else {
228 "select ZUNIQUEIDENTIFIER, coalesce(ZTITLE, ''), coalesce(ZTEXT, ''), ZMODIFICATIONDATE
229 from ZSFNOTE
230 where ZTRASHED = 0
231 and ZARCHIVED = 0
232 and ZENCRYPTED = 0
233 and ZLOCKED = 0
234 and ZPERMANENTLYDELETED = 0"
235 };
236 let mut stmt = self.connection.prepare(sql)?;
237 let rows = stmt.query_map([], |row| {
238 Ok((
239 row.get::<_, String>(0)?,
240 row.get::<_, String>(1)?,
241 row.get::<_, String>(2)?,
242 row.get::<_, Option<f64>>(3)?,
243 ))
244 })?;
245
246 let note_tags = self.note_tag_map()?;
247 let mut results = Vec::new();
248
249 for row in rows {
250 let (identifier, title, text, modified_at) = row?;
251 let modified_at = modified_at.map(|value| value as i64);
252
253 if let Some(since) = since {
254 if modified_at.is_some_and(|value| value < since) {
255 continue;
256 }
257 }
258 if let Some(before) = before {
259 if modified_at.is_some_and(|value| value >= before) {
260 continue;
261 }
262 }
263
264 let tags = note_tags.get(&identifier).cloned().unwrap_or_default();
265 if let Some(tag_filter) = tag_filter {
266 if !tags.iter().any(|candidate| candidate == tag_filter) {
267 continue;
268 }
269 }
270
271 let title_lower = title.to_lowercase();
272 let text_lower = text.to_lowercase();
273 let tag_match = !term.is_empty()
274 && tags
275 .iter()
276 .any(|candidate| candidate.to_lowercase().contains(&term));
277 let title_match = !term.is_empty() && title_lower.contains(&term);
278 let body_match = !term.is_empty() && text_lower.contains(&term);
279
280 if !term.is_empty() && !title_match && !tag_match && !body_match {
281 continue;
282 }
283
284 let rank = if title_match {
285 0
286 } else if tag_match {
287 1
288 } else {
289 2
290 };
291
292 results.push(SearchResult {
293 identifier,
294 title,
295 snippet: if body_match {
296 Some(make_snippet(&text, &text_lower, &term))
297 } else {
298 None
299 },
300 modified_at,
301 rank,
302 });
303 }
304
305 results.sort_by(|left, right| {
306 left.rank
307 .cmp(&right.rank)
308 .then_with(|| right.modified_at.cmp(&left.modified_at))
309 .then_with(|| left.title.to_lowercase().cmp(&right.title.to_lowercase()))
310 .then_with(|| left.identifier.cmp(&right.identifier))
311 });
312
313 Ok(results)
314 }
315
316 pub fn export_notes(&self, tag: Option<&str>) -> Result<Vec<ExportNote>> {
317 let mut stmt = self.connection.prepare(
320 "select distinct n.ZUNIQUEIDENTIFIER, coalesce(n.ZTITLE, ''), coalesce(n.ZTEXT, ''),
321 n.ZPINNED, n.ZCREATIONDATE, n.ZMODIFICATIONDATE
322 from ZSFNOTE n
323 left join Z_5TAGS nt on nt.Z_5NOTES = n.Z_PK
324 left join ZSFNOTETAG t on t.Z_PK = nt.Z_13TAGS
325 where n.ZTRASHED = 0
326 and n.ZENCRYPTED = 0
327 and n.ZLOCKED = 0
328 and n.ZPERMANENTLYDELETED = 0
329 and (?1 is null or t.ZTITLE = ?1)
330 order by lower(coalesce(n.ZTITLE, '')) asc",
331 )?;
332
333 let note_tags = self.note_tag_map()?;
334
335 let rows = stmt.query_map([tag], |row| {
336 Ok((
337 row.get::<_, String>(0)?,
338 row.get::<_, String>(1)?,
339 row.get::<_, String>(2)?,
340 row.get::<_, i64>(3)?,
341 row.get::<_, Option<f64>>(4)?,
342 row.get::<_, Option<f64>>(5)?,
343 ))
344 })?;
345
346 let mut notes = Vec::new();
347 for row in rows {
348 let (identifier, title, text, pinned, created_at, modified_at) = row?;
349 notes.push(ExportNote {
350 tags: note_tags.get(&identifier).cloned().unwrap_or_default(),
351 identifier,
352 title,
353 text,
354 pinned: pinned == 1,
355 created_at: created_at.map(|v| v as i64),
356 modified_at: modified_at.map(|v| v as i64),
357 });
358 }
359
360 Ok(notes)
361 }
362
363 pub fn note_title(&self, note_id: &str) -> Result<Option<String>> {
366 self.connection
367 .query_row(
368 "select coalesce(ZTITLE, '') from ZSFNOTE where ZUNIQUEIDENTIFIER = ?1 limit 1",
369 [note_id],
370 |row| row.get(0),
371 )
372 .optional()
373 .map_err(Into::into)
374 }
375
376 pub fn duplicate_titles(&self) -> Result<Vec<DuplicateGroup>> {
377 let mut stmt = self.connection.prepare(
378 "select coalesce(ZTITLE, ''), ZUNIQUEIDENTIFIER, ZMODIFICATIONDATE
379 from ZSFNOTE
380 where ZTRASHED = 0
381 and ZPERMANENTLYDELETED = 0
382 and trim(coalesce(ZTITLE, '')) <> ''
383 order by lower(trim(coalesce(ZTITLE, ''))) asc, ZMODIFICATIONDATE desc",
384 )?;
385
386 let rows = stmt.query_map([], |row| {
387 Ok((
388 row.get::<_, String>(0)?,
389 row.get::<_, String>(1)?,
390 row.get::<_, Option<f64>>(2)?,
391 ))
392 })?;
393
394 let mut groups = std::collections::BTreeMap::<String, Vec<DuplicateNote>>::new();
395
396 for row in rows {
397 let (title, identifier, modified_at) = row?;
398 let trimmed_title = title.trim().to_string();
399 groups
400 .entry(trimmed_title)
401 .or_default()
402 .push(DuplicateNote {
403 identifier,
404 modified_at: modified_at.map(|value| value.to_string()),
405 });
406 }
407
408 Ok(groups
409 .into_iter()
410 .filter_map(|(title, notes)| {
411 if notes.len() > 1 {
412 Some(DuplicateGroup { title, notes })
413 } else {
414 None
415 }
416 })
417 .collect())
418 }
419
420 pub fn stats_summary(&self) -> Result<StatsSummary> {
421 let mut stmt = self.connection.prepare(
422 "select coalesce(ZTITLE, ''), coalesce(ZTEXT, ''), ZTRASHED, ZARCHIVED, ZPINNED, ZMODIFICATIONDATE
423 from ZSFNOTE
424 where ZPERMANENTLYDELETED = 0",
425 )?;
426
427 let rows = stmt.query_map([], |row| {
428 Ok((
429 row.get::<_, String>(0)?,
430 row.get::<_, String>(1)?,
431 row.get::<_, i64>(2)?,
432 row.get::<_, i64>(3)?,
433 row.get::<_, i64>(4)?,
434 row.get::<_, Option<f64>>(5)?,
435 ))
436 })?;
437
438 let tags = self.tags()?;
439 let mut total_notes = 0usize;
440 let mut pinned_notes = 0usize;
441 let mut tagged_notes = 0usize;
442 let mut archived_notes = 0usize;
443 let mut trashed_notes = 0usize;
444 let mut total_words = 0usize;
445 let mut notes_with_todos = 0usize;
446 let mut oldest_modified: Option<i64> = None;
447 let mut newest_modified: Option<i64> = None;
448
449 let mut tag_counts = std::collections::BTreeMap::<String, usize>::new();
450
451 let note_tags = self.note_tag_map()?;
452
453 for row in rows {
454 let (_title, text, trashed, archived, pinned, modified_at) = row?;
455
456 if trashed == 1 {
457 trashed_notes += 1;
458 continue;
459 }
460
461 total_notes += 1;
462 if pinned == 1 {
463 pinned_notes += 1;
464 }
465 if archived == 1 {
466 archived_notes += 1;
467 }
468 if text.contains("- [ ]") {
469 notes_with_todos += 1;
470 }
471 total_words += text
472 .split_whitespace()
473 .filter(|part| !part.is_empty())
474 .count();
475
476 if let Some(modified_at) = modified_at.map(|value| value as i64) {
477 oldest_modified = Some(match oldest_modified {
478 Some(current) => current.min(modified_at),
479 None => modified_at,
480 });
481 newest_modified = Some(match newest_modified {
482 Some(current) => current.max(modified_at),
483 None => modified_at,
484 });
485 }
486 }
487
488 for (note_id, tags) in note_tags {
489 if self.is_trashed(¬e_id)? {
490 continue;
491 }
492 if !tags.is_empty() {
493 tagged_notes += 1;
494 }
495 for tag in tags {
496 *tag_counts.entry(tag).or_default() += 1;
497 }
498 }
499
500 let mut top_tags = tag_counts.into_iter().collect::<Vec<_>>();
501 top_tags.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
502 top_tags.truncate(10);
503
504 Ok(StatsSummary {
505 total_notes,
506 pinned_notes,
507 tagged_notes,
508 archived_notes,
509 trashed_notes,
510 unique_tags: tags.len(),
511 total_words,
512 notes_with_todos,
513 oldest_modified,
514 newest_modified,
515 top_tags,
516 })
517 }
518
519 pub fn health_summary(&self) -> Result<HealthSummary> {
520 const OLD_TRASH_THRESHOLD: i64 = 30;
521 const LARGE_NOTE_THRESHOLD_BYTES: usize = 100_000;
522
523 let mut stmt = self.connection.prepare(
524 "select ZUNIQUEIDENTIFIER, coalesce(ZTITLE, ''), coalesce(ZTEXT, ''), ZTRASHED, ZARCHIVED, ZMODIFICATIONDATE
525 from ZSFNOTE
526 where ZPERMANENTLYDELETED = 0
527 and ZENCRYPTED = 0
528 and ZLOCKED = 0",
529 )?;
530
531 let rows = stmt.query_map([], |row| {
532 Ok((
533 row.get::<_, String>(0)?,
534 row.get::<_, String>(1)?,
535 row.get::<_, String>(2)?,
536 row.get::<_, i64>(3)?,
537 row.get::<_, i64>(4)?,
538 row.get::<_, Option<f64>>(5)?,
539 ))
540 })?;
541
542 let duplicate_groups = self.duplicate_titles()?;
543 let note_tags = self.note_tag_map()?;
544
545 let mut total_notes = 0usize;
546 let mut empty_notes = Vec::new();
547 let mut untagged_notes = 0usize;
548 let mut old_trashed_notes = Vec::new();
549 let mut large_notes = Vec::new();
550 let mut conflict_notes = Vec::new();
551 let mut max_modified = 0i64;
552
553 let mut rows_cache = Vec::new();
554 for row in rows {
555 let row = row?;
556 if let Some(modified_at) = row.5.map(|value| value as i64) {
557 max_modified = max_modified.max(modified_at);
558 }
559 rows_cache.push(row);
560 }
561
562 for (identifier, title, text, trashed, archived, modified_at) in rows_cache {
563 let display_title = if title.trim().is_empty() {
564 "(untitled)".to_string()
565 } else {
566 title.trim().to_string()
567 };
568
569 if trashed == 0 {
570 total_notes += 1;
571
572 if text.trim().is_empty() {
573 empty_notes.push(HealthNoteIssue {
574 identifier: identifier.clone(),
575 title: display_title.clone(),
576 });
577 }
578
579 if !note_tags
580 .get(&identifier)
581 .map(|tags| !tags.is_empty())
582 .unwrap_or(false)
583 {
584 untagged_notes += 1;
585 }
586
587 let size_bytes = text.len();
588 if size_bytes >= LARGE_NOTE_THRESHOLD_BYTES {
589 large_notes.push(LargeNoteIssue {
590 identifier: identifier.clone(),
591 title: display_title.clone(),
592 size_bytes,
593 });
594 }
595
596 let lower_title = display_title.to_lowercase();
597 if lower_title.contains("conflict") || lower_title.contains("copy") {
598 conflict_notes.push(HealthNoteIssue {
599 identifier,
600 title: display_title,
601 });
602 }
603 } else if archived == 0 {
604 let modified_at = modified_at.map(|value| value as i64).unwrap_or_default();
605 if max_modified.saturating_sub(modified_at) >= OLD_TRASH_THRESHOLD {
606 old_trashed_notes.push(HealthNoteIssue {
607 identifier,
608 title: display_title,
609 });
610 }
611 }
612 }
613
614 Ok(HealthSummary {
615 total_notes,
616 duplicate_groups: duplicate_groups.len(),
617 duplicate_notes: duplicate_groups.iter().map(|group| group.notes.len()).sum(),
618 empty_notes,
619 untagged_notes,
620 old_trashed_notes,
621 large_notes,
622 conflict_notes,
623 })
624 }
625
626 pub fn untagged(&self, search: Option<&str>) -> Result<Vec<NoteListItem>> {
627 let like = format!("%{}%", search.unwrap_or_default());
628 let mut stmt = self.connection.prepare(
629 "select n.ZUNIQUEIDENTIFIER, coalesce(n.ZTITLE, '')
630 from ZSFNOTE n
631 where n.ZTRASHED = 0
632 and n.ZARCHIVED = 0
633 and n.ZENCRYPTED = 0
634 and n.ZLOCKED = 0
635 and n.ZPERMANENTLYDELETED = 0
636 and not exists (
637 select 1
638 from Z_5TAGS nt
639 where nt.Z_5NOTES = n.Z_PK
640 )
641 and (coalesce(n.ZTITLE, '') like ?1 or coalesce(n.ZTEXT, '') like ?1)
642 order by lower(coalesce(n.ZTITLE, '')) asc",
643 )?;
644 let rows = stmt.query_map([like], |row| {
645 Ok(NoteListItem {
646 identifier: row.get(0)?,
647 title: row.get(1)?,
648 })
649 })?;
650 rows.collect::<std::result::Result<Vec<_>, _>>()
651 .map_err(Into::into)
652 }
653
654 pub fn todo(&self, search: Option<&str>) -> Result<Vec<NoteListItem>> {
655 self.simple_filtered_list("ZTODOINCOMPLETED > 0", search)
656 }
657
658 pub fn today(&self, search: Option<&str>) -> Result<Vec<NoteListItem>> {
659 self.simple_filtered_list("ZSHOWNINTODAYWIDGET > 0", search)
660 }
661
662 pub fn locked(&self, search: Option<&str>) -> Result<Vec<NoteListItem>> {
663 let like = format!("%{}%", search.unwrap_or_default());
664 let mut stmt = self.connection.prepare(
665 "select ZUNIQUEIDENTIFIER, coalesce(ZTITLE, '')
666 from ZSFNOTE
667 where ZLOCKED > 0
668 and ZPERMANENTLYDELETED = 0
669 and (coalesce(ZTITLE, '') like ?1 or coalesce(ZTEXT, '') like ?1)
670 order by lower(coalesce(ZTITLE, '')) asc",
671 )?;
672 let rows = stmt.query_map([like], |row| {
673 Ok(NoteListItem {
674 identifier: row.get(0)?,
675 title: row.get(1)?,
676 })
677 })?;
678 rows.collect::<std::result::Result<Vec<_>, _>>()
679 .map_err(Into::into)
680 }
681
682 pub fn note_files(&self, note_id: &str) -> Result<Vec<NoteFile>> {
686 let mut stmt = self.connection.prepare(
687 "SELECT f.ZFILENAME, f.ZUNIQUEIDENTIFIER
688 FROM ZSFNOTEFILE f
689 JOIN ZSFNOTE n ON f.ZNOTE = n.Z_PK
690 WHERE n.ZUNIQUEIDENTIFIER = ?1
691 AND f.ZPERMANENTLYDELETED = 0",
692 )?;
693 let rows = stmt.query_map([note_id], |row| {
694 Ok(NoteFile {
695 filename: row.get(0)?,
696 file_uuid: row.get(1)?,
697 })
698 })?;
699 rows.collect::<std::result::Result<Vec<_>, _>>()
700 .map_err(Into::into)
701 }
702
703 fn simple_filtered_list(
704 &self,
705 predicate: &str,
706 search: Option<&str>,
707 ) -> Result<Vec<NoteListItem>> {
708 let like = format!("%{}%", search.unwrap_or_default());
709 let sql = format!(
710 "select ZUNIQUEIDENTIFIER, coalesce(ZTITLE, '')
711 from ZSFNOTE
712 where {predicate}
713 and ZTRASHED = 0
714 and ZARCHIVED = 0
715 and ZENCRYPTED = 0
716 and ZLOCKED = 0
717 and ZPERMANENTLYDELETED = 0
718 and (coalesce(ZTITLE, '') like ?1 or coalesce(ZTEXT, '') like ?1)
719 order by lower(coalesce(ZTITLE, '')) asc"
720 );
721 let mut stmt = self.connection.prepare(&sql)?;
722 let rows = stmt.query_map([like], |row| {
723 Ok(NoteListItem {
724 identifier: row.get(0)?,
725 title: row.get(1)?,
726 })
727 })?;
728 rows.collect::<std::result::Result<Vec<_>, _>>()
729 .map_err(Into::into)
730 }
731
732 fn note_tag_map(&self) -> Result<std::collections::BTreeMap<String, Vec<String>>> {
733 let mut stmt = self.connection.prepare(
734 "select n.ZUNIQUEIDENTIFIER, t.ZTITLE
735 from ZSFNOTE n
736 left join Z_5TAGS nt on nt.Z_5NOTES = n.Z_PK
737 left join ZSFNOTETAG t on t.Z_PK = nt.Z_13TAGS
738 where n.ZPERMANENTLYDELETED = 0",
739 )?;
740
741 let rows = stmt.query_map([], |row| {
742 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
743 })?;
744
745 let mut map = std::collections::BTreeMap::<String, Vec<String>>::new();
746 for row in rows {
747 let (note_id, tag) = row?;
748 let entry = map.entry(note_id).or_default();
749 if let Some(tag) = tag {
750 entry.push(tag);
751 }
752 }
753 Ok(map)
754 }
755
756 fn is_trashed(&self, note_id: &str) -> Result<bool> {
757 let mut stmt = self
758 .connection
759 .prepare("select ZTRASHED from ZSFNOTE where ZUNIQUEIDENTIFIER = ?1 limit 1")?;
760 let trashed = stmt.query_row([note_id], |row| row.get::<_, i64>(0))?;
761 Ok(trashed == 1)
762 }
763}
764
765fn make_snippet(text: &str, text_lower: &str, term: &str) -> String {
766 let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
767 let normalized_lower = text_lower.split_whitespace().collect::<Vec<_>>().join(" ");
768 let index = normalized_lower.find(term).unwrap_or(0);
769 let start = index.saturating_sub(30);
770 let end = (index + term.len() + 50).min(normalized.len());
771 normalized[start..end].trim().to_string()
772}
773
774#[cfg(test)]
775mod tests {
776 use rusqlite::Connection;
777
778 use super::{
779 BearDb, DuplicateGroup, DuplicateNote, HealthNoteIssue, HealthSummary, NoteListItem,
780 SearchResult, StatsSummary,
781 };
782
783 fn test_db() -> BearDb {
784 let connection = Connection::open_in_memory().expect("in-memory db");
785 connection
786 .execute_batch(
787 "
788 create table ZSFNOTE (
789 Z_PK integer primary key,
790 ZTRASHED integer,
791 ZARCHIVED integer,
792 ZPINNED integer,
793 ZENCRYPTED integer,
794 ZLOCKED integer,
795 ZPERMANENTLYDELETED integer,
796 ZTODOINCOMPLETED integer,
797 ZSHOWNINTODAYWIDGET integer,
798 ZCREATIONDATE integer,
799 ZMODIFICATIONDATE integer,
800 ZTITLE text,
801 ZTEXT text,
802 ZUNIQUEIDENTIFIER text
803 );
804 create table ZSFNOTETAG (
805 Z_PK integer primary key,
806 ZENCRYPTED integer,
807 ZTITLE text
808 );
809 create table Z_5TAGS (
810 Z_5NOTES integer,
811 Z_13TAGS integer
812 );
813 insert into ZSFNOTE values
814 (1, 0, 0, 1, 0, 0, 0, 1, 1, 5, 10, 'Alpha', 'alpha body - [ ]', 'NOTE-1'),
815 (2, 0, 1, 0, 0, 0, 0, 0, 0, 15, 20, 'Beta', 'beta body', 'NOTE-2'),
816 (3, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 'Trash', 'trashed', 'NOTE-3'),
817 (4, 0, 0, 0, 0, 0, 0, 0, 0, 35, 40, 'Alpha', 'another alpha with rust body', 'NOTE-4'),
818 (5, 0, 0, 0, 0, 0, 0, 0, 0, 45, 50, ' ', 'blank title', 'NOTE-5'),
819 (6, 0, 0, 0, 0, 0, 0, 0, 0, 55, 60, 'Conflict copy', '', 'NOTE-6'),
820 (7, 0, 0, 0, 0, 0, 0, 0, 0, 65, 70, 'Gamma', 'body mention with rust across
821lines', 'NOTE-7'),
822 (8, 0, 0, 0, 0, 0, 0, 0, 0, 75, 80, 'Rust title', 'no body hit here', 'NOTE-8');
823 insert into ZSFNOTETAG values
824 (10, 0, 'work'),
825 (11, 0, 'misc'),
826 (12, 0, 'rust');
827 insert into Z_5TAGS values
828 (1, 10),
829 (2, 10),
830 (3, 11),
831 (7, 12);
832 ",
833 )
834 .expect("schema should be created");
835
836 BearDb::from_connection(connection)
837 }
838
839 #[test]
840 fn finds_note_by_title() {
841 let db = test_db();
842 let note = db
843 .find_note(None, Some("Alpha"), false)
844 .expect("note should exist");
845 assert_eq!(note.text, "another alpha with rust body");
846 }
847
848 #[test]
849 fn searches_non_trashed_notes() {
850 let db = test_db();
851 let notes = db
852 .search(Some("alpha body"), None, false, None, None)
853 .expect("search should work");
854 assert_eq!(
855 notes,
856 vec![SearchResult {
857 identifier: "NOTE-1".into(),
858 title: "Alpha".into(),
859 snippet: Some("alpha body - [ ]".into()),
860 modified_at: Some(10),
861 rank: 2,
862 }]
863 );
864 }
865
866 #[test]
867 fn lists_notes_for_tag_without_trashed_entries() {
868 let db = test_db();
869 let notes = db
870 .notes_for_tags(&["work".into(), "misc".into()], false)
871 .expect("tag lookup should work");
872 assert_eq!(
873 notes,
874 vec![
875 NoteListItem {
876 identifier: "NOTE-1".into(),
877 title: "Alpha".into()
878 },
879 NoteListItem {
880 identifier: "NOTE-2".into(),
881 title: "Beta".into()
882 }
883 ]
884 );
885 }
886
887 #[test]
888 fn finds_duplicate_titles() {
889 let db = test_db();
890 let groups = db
891 .duplicate_titles()
892 .expect("duplicate detection should work");
893
894 assert_eq!(
895 groups,
896 vec![DuplicateGroup {
897 title: "Alpha".into(),
898 notes: vec![
899 DuplicateNote {
900 identifier: "NOTE-4".into(),
901 modified_at: Some("40".into()),
902 },
903 DuplicateNote {
904 identifier: "NOTE-1".into(),
905 modified_at: Some("10".into()),
906 },
907 ],
908 }]
909 );
910 }
911
912 #[test]
913 fn computes_stats_summary() {
914 let db = test_db();
915 let summary = db.stats_summary().expect("stats should compute");
916
917 assert_eq!(
918 summary,
919 StatsSummary {
920 total_notes: 7,
921 pinned_notes: 1,
922 tagged_notes: 3,
923 archived_notes: 1,
924 trashed_notes: 1,
925 unique_tags: 3,
926 total_words: 24,
927 notes_with_todos: 1,
928 oldest_modified: Some(10),
929 newest_modified: Some(80),
930 top_tags: vec![("work".into(), 2), ("rust".into(), 1)],
931 }
932 );
933 }
934
935 #[test]
936 fn computes_health_summary() {
937 let db = test_db();
938 let summary = db.health_summary().expect("health should compute");
939
940 assert_eq!(
941 summary,
942 HealthSummary {
943 total_notes: 7,
944 duplicate_groups: 1,
945 duplicate_notes: 2,
946 empty_notes: vec![HealthNoteIssue {
947 identifier: "NOTE-6".into(),
948 title: "Conflict copy".into(),
949 }],
950 untagged_notes: 4,
951 old_trashed_notes: vec![HealthNoteIssue {
952 identifier: "NOTE-3".into(),
953 title: "Trash".into(),
954 }],
955 large_notes: vec![],
956 conflict_notes: vec![HealthNoteIssue {
957 identifier: "NOTE-6".into(),
958 title: "Conflict copy".into(),
959 }],
960 }
961 );
962 }
963
964 #[test]
965 fn search_ranks_title_match_over_tag_and_body() {
966 let db = test_db();
967 let results = db
968 .search(Some("rust"), None, false, None, None)
969 .expect("search should work");
970
971 assert_eq!(
972 results
973 .iter()
974 .map(|result| result.identifier.as_str())
975 .collect::<Vec<_>>(),
976 vec!["NOTE-8", "NOTE-7", "NOTE-4"]
977 );
978 }
979
980 #[test]
981 fn search_applies_since_and_before_filters() {
982 let db = test_db();
983 let results = db
984 .search(Some("rust"), None, false, Some(50), Some(80))
985 .expect("search should work");
986
987 assert_eq!(
988 results
989 .iter()
990 .map(|result| result.identifier.as_str())
991 .collect::<Vec<_>>(),
992 vec!["NOTE-7"]
993 );
994 }
995
996 #[test]
997 fn search_normalizes_snippets() {
998 let db = test_db();
999 let result = db
1000 .search(Some("rust"), None, false, None, None)
1001 .expect("search should work")
1002 .into_iter()
1003 .find(|result| result.identifier == "NOTE-7")
1004 .expect("body match should exist");
1005
1006 assert_eq!(
1007 result.snippet,
1008 Some("body mention with rust across lines".into())
1009 );
1010 }
1011
1012 #[test]
1013 fn exports_notes_with_tags() {
1014 let db = test_db();
1015 let notes = db
1016 .export_notes(Some("rust"))
1017 .expect("export query should work");
1018
1019 assert_eq!(notes.len(), 1);
1020 assert_eq!(notes[0].identifier, "NOTE-7");
1021 assert_eq!(notes[0].tags, vec!["rust".to_string()]);
1022 assert_eq!(notes[0].created_at, Some(65));
1023 }
1024}