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}