Skip to main content

bear_query/
models.rs

1//! Data models for Bear database entities.
2//!
3//! This module contains all the types representing Bear's database entities:
4//! notes, tags, and their identifiers.
5
6use rusqlite::types::{FromSql, FromSqlResult, ToSqlOutput, ValueRef};
7use rusqlite::{Row, ToSql};
8use std::collections::{HashMap, HashSet};
9use time::OffsetDateTime;
10
11/// Internal database ID wrapper.
12///
13/// This wraps SQLite's INTEGER PRIMARY KEY values.
14#[derive(Copy, Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Hash)]
15pub(crate) struct CoreDbId(pub(crate) i64);
16
17/// Internal Core Data note identifier.
18///
19/// This wraps the note's SQLite primary key (`Z_PK` in Bear's schema).
20/// This ID is internal to the database and should not be exposed in public APIs.
21/// Use `NoteId` for the public API instead.
22#[derive(Copy, Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Hash)]
23pub(crate) struct CoreDbNoteId(pub(crate) CoreDbId);
24
25impl CoreDbNoteId {
26  // Internal construction is done via FromSql trait when reading from database
27}
28
29/// Bear note identifier (UUID-based).
30///
31/// This wraps Bear's UUID identifier for notes. This is the identifier that Bear
32/// uses in its UI, x-callback-url API, and for syncing notes across devices.
33///
34/// # Creating IDs
35///
36/// You can create a `NoteId` from a UUID string:
37///
38/// ```
39/// use bear_query::NoteId;
40///
41/// let note_id = NoteId::new("ABC123-DEF456-...".to_string());
42/// ```
43///
44/// # Usage
45///
46/// Use this ID for:
47/// - Opening notes in Bear via x-callback-url: `bear://x-callback-url/open-note?id={uuid}`
48/// - Storing stable references to notes that work across devices
49/// - Matching notes in sync operations
50///
51/// This is Bear's primary identifier for notes.
52#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Hash)]
53pub struct NoteId(String);
54
55impl NoteId {
56  /// Creates a new `NoteId` from a UUID string.
57  ///
58  /// # Example
59  ///
60  /// ```
61  /// use bear_query::NoteId;
62  ///
63  /// let note_id = NoteId::new("ABC123-DEF456-...".to_string());
64  /// ```
65  pub fn new(uuid: String) -> Self {
66    NoteId(uuid)
67  }
68
69  /// Returns the UUID as a string slice.
70  ///
71  /// # Example
72  ///
73  /// ```
74  /// use bear_query::NoteId;
75  ///
76  /// let note_id = NoteId::new("ABC123-DEF456-...".to_string());
77  /// assert_eq!(note_id.as_str(), "ABC123-DEF456-...");
78  /// ```
79  pub fn as_str(&self) -> &str {
80    &self.0
81  }
82
83  /// Consumes the NoteId and returns the inner UUID string.
84  pub fn into_string(self) -> String {
85    self.0
86  }
87}
88
89/// Unique identifier for a Bear tag.
90///
91/// This wraps the tag's SQLite primary key.
92#[derive(Copy, Clone, Debug, Ord, PartialOrd, Eq, PartialEq, Hash)]
93pub struct TagId(pub(crate) CoreDbId);
94
95impl TagId {
96  /// Creates a new `TagId` from an `i64` primary key value.
97  pub fn new(id: i64) -> Self {
98    TagId(CoreDbId(id))
99  }
100
101  /// Returns the underlying `i64` value of this ID.
102  pub fn as_i64(self) -> i64 {
103    self.0.0
104  }
105}
106
107impl FromSql for CoreDbId {
108  fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
109    Ok(Self(value.as_i64()?))
110  }
111}
112
113impl FromSql for CoreDbNoteId {
114  fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
115    Ok(Self(FromSql::column_result(value)?))
116  }
117}
118
119impl FromSql for TagId {
120  fn column_result(value: ValueRef<'_>) -> FromSqlResult<Self> {
121    Ok(Self(FromSql::column_result(value)?))
122  }
123}
124
125impl ToSql for CoreDbId {
126  fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
127    self.0.to_sql()
128  }
129}
130
131impl ToSql for CoreDbNoteId {
132  fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
133    self.0.to_sql()
134  }
135}
136
137/// A tag from Bear's database.
138///
139/// Tags in Bear organize notes hierarchically (e.g., "work/projects/bear-query").
140///
141/// # Nullable Fields
142///
143/// - **`name`**: Tag name. In theory should never be NULL, but we handle it defensively.
144///   A tag without a name would be unusual but possible in a corrupted database.
145/// - **`modified`**: Timestamp of last modification. May be `None` for tags that have
146///   never been explicitly modified.
147///
148/// # Example
149///
150/// ```no_run
151/// # use bear_query::BearDb;
152/// # fn main() -> Result<(), bear_query::BearError> {
153/// let db = BearDb::new()?;
154/// let tags = db.tags()?;
155///
156/// for tag in tags.iter() {
157///     let name = tag.name().unwrap_or("[unnamed]");
158///     match tag.modified() {
159///         Some(modified) => println!("Tag '{}' modified: {}", name, modified),
160///         None => println!("Tag '{}' (never modified)", name),
161///     }
162/// }
163/// # Ok(())
164/// # }
165/// ```
166#[derive(Debug, Clone)]
167pub struct Tag {
168  id: TagId,
169  name: Option<String>,
170  modified: Option<OffsetDateTime>,
171}
172
173impl Tag {
174  /// Returns the tag's primary key identifier.
175  ///
176  /// This is always present (never NULL) and stable across the tag's lifetime.
177  pub fn id(&self) -> TagId {
178    self.id
179  }
180
181  /// Returns the tag's name, if present.
182  ///
183  /// Returns `None` in the rare case of a tag without a name (corrupted data).
184  ///
185  /// Tag names can be hierarchical, using `/` as separator:
186  /// - `"work"` - top-level tag
187  /// - `"work/projects"` - nested tag
188  /// - `"work/projects/bear-query"` - deeply nested tag
189  pub fn name(&self) -> Option<&str> {
190    self.name.as_deref()
191  }
192
193  /// Returns the timestamp of the tag's last modification, if available.
194  ///
195  /// Returns `None` for tags that have never been explicitly modified.
196  pub fn modified(&self) -> Option<OffsetDateTime> {
197    self.modified
198  }
199}
200
201/// Helper to construct Tag from a database row
202pub(crate) fn tag_from_row(row: &Row) -> rusqlite::Result<Tag> {
203  Ok(Tag {
204    id: row.get("id")?,
205    name: row.get("name")?,
206    modified: row.get("modified")?,
207  })
208}
209
210/// Collection of tags from Bear's database.
211///
212/// This is a map of tag IDs to tags, returned by `BearDb::tags()`.
213/// It provides convenient methods for looking up tags and converting
214/// tag ID sets into tag names.
215///
216/// # Example
217///
218/// ```no_run
219/// # use bear_query::BearDb;
220/// # fn main() -> Result<(), bear_query::BearError> {
221/// let db = BearDb::new()?;
222/// let tags = db.tags()?;
223///
224/// println!("Total tags: {}", tags.count());
225///
226/// for tag in tags.iter() {
227///     if let Some(name) = tag.name() {
228///         println!("Tag: {}", name);
229///     }
230/// }
231/// # Ok(())
232/// # }
233/// ```
234#[derive(Debug)]
235pub struct TagsMap {
236  pub(crate) tags: HashMap<TagId, Tag>,
237}
238
239impl TagsMap {
240  /// Gets a tag by its ID.
241  pub fn get(
242    &self,
243    tag_id: &TagId,
244  ) -> Option<&Tag> {
245    self.tags.get(tag_id)
246  }
247
248  /// Returns the number of tags.
249  pub fn count(&self) -> usize {
250    self.tags.len()
251  }
252
253  /// Returns an iterator over all tags.
254  pub fn iter(&self) -> impl Iterator<Item = &Tag> {
255    self.tags.values()
256  }
257
258  /// Returns the names of the tags with the given IDs.
259  ///
260  /// Tags with NULL names are omitted from the result.
261  pub fn names(
262    &self,
263    tag_ids: &HashSet<TagId>,
264  ) -> HashSet<String> {
265    tag_ids
266      .iter()
267      .filter_map(|id| self.get(id).and_then(|t| t.name.clone()))
268      .collect()
269  }
270}
271
272/// A note from Bear's database.
273///
274/// # Nullable Fields
275///
276/// Only one field in Bear notes can be NULL in the database:
277///
278/// - **`content`**: Empty notes may have `None` for content.
279///
280/// # Always-Present Fields
281///
282/// The following fields are **always present** (never NULL):
283///
284/// - **`title`**: All notes have titles (may be empty string, but never NULL)
285/// - **`unique_id`**: Bear's UUID identifier (always present)
286/// - **`id`**: Primary key (always present)
287/// - **`modified`**, **`created`**: Timestamps (always present)
288/// - **`is_pinned`**: Boolean flag (always present)
289///
290/// # Identifiers
291///
292/// Bear notes use UUIDs as their primary identifier:
293///
294/// ## UUID (`id()`)
295/// - Type: `&NoteId` (Bear's UUID like 'ABC123-DEF456-...')
296/// - **Always present** (never NULL)
297/// - Stable across the lifetime of the note and across devices
298/// - Use this for all programmatic references and API calls
299/// - Bear uses this for syncing and x-callback-url schemes
300/// - Used in Bear's x-callback-url API (e.g., `bear://x-callback-url/open-note?id=UUID`)
301///
302/// The internal Core Data primary key is not exposed in the public API.
303///
304/// # Example
305///
306/// ```no_run
307/// # use bear_query::{BearDb, NotesQuery};
308/// # fn main() -> Result<(), bear_query::BearError> {
309/// let db = BearDb::new()?;
310/// let notes = db.notes(NotesQuery::default())?;
311///
312/// for note in notes {
313///     let note_id = note.id();
314///     let title = note.title();
315///
316///     // Only content may be None
317///     let content = note.content().unwrap_or("");
318///
319///     println!("Note {}: {} ({} bytes)", note_id.as_str(), title, content.len());
320/// }
321/// # Ok(())
322/// # }
323/// ```
324#[derive(Debug)]
325pub struct Note {
326  _core_db_id: CoreDbNoteId,
327  id: NoteId,
328  title: String,
329  content: Option<String>,
330  modified: OffsetDateTime,
331  created: OffsetDateTime,
332  is_pinned: bool,
333}
334
335impl Note {
336  /// Returns the note's internal Core Data ID (for internal use only).
337  ///
338  /// This is used internally for database queries and joins.
339  /// External users should use `id()` which returns Bear's UUID.
340  pub(crate) fn _core_db_id(&self) -> CoreDbNoteId {
341    self._core_db_id
342  }
343
344  /// Returns the note's Bear identifier.
345  ///
346  /// This is Bear's UUID identifier, which is:
347  /// - Always present (never NULL)
348  /// - Stable across the note's lifetime
349  /// - Works across devices (syncing)
350  /// - Used in Bear's x-callback-url API
351  ///
352  /// Use this for:
353  /// - Opening notes in Bear
354  /// - Storing references to notes
355  /// - Matching notes across devices
356  ///
357  /// # Example
358  ///
359  /// ```no_run
360  /// # use bear_query::{BearDb, NotesQuery};
361  /// # fn main() -> Result<(), bear_query::BearError> {
362  /// # let db = BearDb::new()?;
363  /// # let notes = db.notes(NotesQuery::default())?;
364  /// # let note = &notes[0];
365  /// let note_id = note.id();
366  /// println!("Open in Bear: bear://x-callback-url/open-note?id={}", note_id.as_str());
367  /// # Ok(())
368  /// # }
369  /// ```
370  pub fn id(&self) -> &NoteId {
371    &self.id
372  }
373
374  /// Returns the note's title.
375  ///
376  /// All notes have titles (this is never NULL), though the title may be an empty string.
377  ///
378  /// # Example
379  ///
380  /// ```no_run
381  /// # use bear_query::{BearDb, NotesQuery};
382  /// # fn main() -> Result<(), bear_query::BearError> {
383  /// # let db = BearDb::new()?;
384  /// # let notes = db.notes(NotesQuery::default())?;
385  /// # let note = &notes[0];
386  /// let title = note.title();
387  /// if title.is_empty() {
388  ///     println!("[Untitled]");
389  /// } else {
390  ///     println!("Title: {}", title);
391  /// }
392  /// # Ok(())
393  /// # }
394  /// ```
395  pub fn title(&self) -> &str {
396    &self.title
397  }
398
399  /// Returns the note's content (Markdown), if present.
400  ///
401  /// Returns `None` for empty notes or notes with NULL content.
402  ///
403  /// # Example
404  ///
405  /// ```no_run
406  /// # use bear_query::{BearDb, NotesQuery};
407  /// # fn main() -> Result<(), bear_query::BearError> {
408  /// # let db = BearDb::new()?;
409  /// # let notes = db.notes(NotesQuery::default())?;
410  /// # let note = &notes[0];
411  /// let content = note.content().unwrap_or("");
412  /// println!("Content length: {} bytes", content.len());
413  /// # Ok(())
414  /// # }
415  /// ```
416  pub fn content(&self) -> Option<&str> {
417    self.content.as_deref()
418  }
419
420  /// Returns the timestamp of the note's last modification.
421  ///
422  /// This is always present (never NULL).
423  pub fn modified(&self) -> OffsetDateTime {
424    self.modified
425  }
426
427  /// Returns the timestamp when the note was created.
428  ///
429  /// This is always present (never NULL).
430  pub fn created(&self) -> OffsetDateTime {
431    self.created
432  }
433
434  /// Returns whether the note is pinned.
435  ///
436  /// This is always present (never NULL).
437  pub fn is_pinned(&self) -> bool {
438    self.is_pinned
439  }
440}
441
442/// Helper to construct Note from a database row
443pub(crate) fn note_from_row(row: &Row) -> rusqlite::Result<Note> {
444  Ok(Note {
445    _core_db_id: row.get("core_db_id")?,
446    id: NoteId::new(row.get("id")?),
447    title: row.get("title")?,
448    content: row.get("content")?,
449    created: row.get("created")?,
450    modified: row.get("modified")?,
451    is_pinned: row.get("is_pinned")?,
452  })
453}
454
455#[cfg(test)]
456mod tests {
457  use super::*;
458
459  /// Test NoteId::new and as_str
460  #[test]
461  fn test_note_id_construction() {
462    let uuid = "ABC123-DEF456-GHI789".to_string();
463    let note_id = NoteId::new(uuid.clone());
464    assert_eq!(note_id.as_str(), &uuid);
465
466    // Test round-trip
467    let uuid2 = "note-uuid-12345".to_string();
468    let note_id2 = NoteId::new(uuid2.clone());
469    assert_eq!(note_id2.into_string(), uuid2);
470  }
471
472  /// Test TagId::new and as_i64
473  #[test]
474  fn test_bear_tag_id_construction() {
475    let tag_id = TagId::new(42);
476    assert_eq!(tag_id.as_i64(), 42);
477
478    // Test round-trip
479    let id_value = 12345;
480    let tag_id = TagId::new(id_value);
481    assert_eq!(tag_id.as_i64(), id_value);
482  }
483
484  /// Test NoteId equality and hashing
485  #[test]
486  fn test_note_id_equality() {
487    let id1 = NoteId::new("ABC123".to_string());
488    let id2 = NoteId::new("ABC123".to_string());
489    let id3 = NoteId::new("DEF456".to_string());
490
491    assert_eq!(id1, id2);
492    assert_ne!(id1, id3);
493
494    // Test that they can be used in HashSet
495    let mut set = std::collections::HashSet::new();
496    set.insert(id1);
497    assert!(set.contains(&id2));
498    assert!(!set.contains(&id3));
499  }
500
501  /// Test TagId equality and hashing
502  #[test]
503  fn test_bear_tag_id_equality() {
504    let id1 = TagId::new(42);
505    let id2 = TagId::new(42);
506    let id3 = TagId::new(43);
507
508    assert_eq!(id1, id2);
509    assert_ne!(id1, id3);
510
511    // Test that they can be used in HashSet
512    let mut set = std::collections::HashSet::new();
513    set.insert(id1);
514    assert!(set.contains(&id2));
515    assert!(!set.contains(&id3));
516  }
517}