Skip to main content

bear_cli/
db.rs

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/// A file attachment belonging to a Bear note.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct NoteFile {
92    /// Original filename as stored in Bear (e.g. `image.png`)
93    pub filename: String,
94    /// Bear's internal UUID for the file — used as the subdirectory name under
95    /// `Application Data/Local Files/Note Images/`
96    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        // LEFT JOIN + NULL guard lets a single query handle both the filtered and
318        // unfiltered cases without duplicating the row-mapping logic.
319        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    /// Returns the title of the note with the given identifier, or `None` if
364    /// the note does not exist.
365    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(&note_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    /// Returns all file attachments for the note with the given identifier.
683    /// The `file_uuid` field can be used to locate the file on disk:
684    /// `Application Data/Local Files/Note Images/{file_uuid}/{filename}`
685    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}