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 = ¬es[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 = ¬es[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 = ¬es[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}