biblion/db/mod.rs
1//! Database access layer — direct SQLite reads on Zotero's databases.
2//!
3//! # The key insight
4//!
5//! Zotero stores everything in SQLite. The Python MCP server was slow because
6//! it went through BBT JSON-RPC (a JavaScript plugin inside Zotero's Electron
7//! app) to read this same data. By reading SQLite directly, we eliminate the
8//! dominant bottleneck: ~300ms per BBT RPC call → <1ms per SQLite query.
9//!
10//! # Two databases
11//!
12//! - **`zotero.sqlite`** — all item metadata, collections, tags, creators,
13//! attachments, notes. Uses an EAV (Entity-Attribute-Value) schema:
14//! `items → itemData → itemDataValues` with field IDs mapped via `fields`.
15//!
16//! - **`better-bibtex.migrated`** — citekey assignments. Maps Zotero item keys
17//! (like "9MS26VH5") to citation keys (like "demilloHintsTestData1978").
18//! This file may not exist if BBT isn't installed.
19//!
20//! Both are opened read-only. We never write to Zotero's databases directly —
21//! writes go through the Zotero Web API.
22
23pub mod bbt;
24pub mod zotero;
25
26use anyhow::Result;
27use std::path::Path;
28
29/// Holds connections to both Zotero databases.
30///
31/// Created once at startup and shared (by reference) across all tool calls.
32/// Both connections are read-only and held open for the process lifetime.
33pub struct DbPool {
34 pub zotero: Option<zotero::ZoteroDb>,
35 pub bbt: Option<bbt::BbtDb>,
36}
37
38impl DbPool {
39 /// Open both databases. Non-fatal: if a database doesn't exist or can't
40 /// be opened, the corresponding field is None and tools that need it
41 /// return a clear error.
42 pub fn open(zotero_path: &Path, bbt_path: &Path) -> Self {
43 let zotero = match zotero::ZoteroDb::open(zotero_path) {
44 Ok(db) => Some(db),
45 Err(e) => {
46 eprintln!("[biblion] Warning: cannot open zotero.sqlite: {e}");
47 None
48 }
49 };
50 let bbt = match bbt::BbtDb::open(bbt_path) {
51 Ok(db) => Some(db),
52 Err(e) => {
53 eprintln!("[biblion] Warning: cannot open better-bibtex.migrated: {e}");
54 None
55 }
56 };
57 Self { zotero, bbt }
58 }
59
60 /// Create an empty pool (for testing when no databases are available).
61 pub fn empty() -> Self {
62 Self {
63 zotero: None,
64 bbt: None,
65 }
66 }
67
68 /// Get the Zotero database, returning a clear error if unavailable.
69 pub fn zotero(&self) -> Result<&zotero::ZoteroDb> {
70 self.zotero.as_ref().ok_or_else(|| {
71 anyhow::anyhow!("Zotero database not available. Check ZOTERO_SQLITE_PATH.")
72 })
73 }
74
75 /// Get the BBT database, returning a clear error if unavailable.
76 pub fn bbt(&self) -> Result<&bbt::BbtDb> {
77 self.bbt.as_ref().ok_or_else(|| {
78 anyhow::anyhow!("Better BibTeX database not available. Check BBT_MIGRATED_PATH.")
79 })
80 }
81
82 /// Resolve a citekey to an item key, trying all available sources.
83 ///
84 /// Priority:
85 /// 1. `zotero.sqlite` citationKey field (most complete, 99.9% coverage)
86 /// 2. `better-bibtex.migrated` (for items not yet synced to Zotero)
87 pub fn item_key_for_citekey(&self, citekey: &str) -> Result<Option<String>> {
88 // Try zotero.sqlite first (most reliable)
89 if let Some(zdb) = &self.zotero
90 && let Ok(Some(key)) = bbt::item_key_from_zotero_sqlite(zdb.conn(), citekey)
91 {
92 return Ok(Some(key));
93 }
94 // Fallback to BBT migrated
95 if let Some(bdb) = &self.bbt
96 && let Ok(Some(key)) = bdb.item_key_for_citekey(citekey)
97 {
98 return Ok(Some(key));
99 }
100 Ok(None)
101 }
102
103 /// Resolve an item key to a citekey, trying all available sources.
104 pub fn citekey_for_item_key(&self, item_key: &str) -> Option<String> {
105 // Try zotero.sqlite first
106 if let Some(zdb) = &self.zotero
107 && let Ok(Some(ck)) = bbt::citekey_from_zotero_sqlite(zdb.conn(), item_key)
108 {
109 return Some(ck);
110 }
111 // Fallback to BBT migrated
112 if let Some(bdb) = &self.bbt
113 && let Ok(Some(ck)) = bdb.citekey_for_item_key(item_key)
114 {
115 return Some(ck);
116 }
117 None
118 }
119}