biblion/api/
bbt_rpc.rs

1//! Better BibTeX JSON-RPC client.
2//!
3//! # When is this used?
4//!
5//! Only for 3 tools that need BBT's CSL formatting engine:
6//! - `zotero_get_bibtex` — export items as BibTeX/BibLaTeX
7//! - `zotero_get_bibliography` — formatted bibliography (APA, IEEE, etc.)
8//! - `zotero_export_bibtex` — export a collection as BibTeX file
9//!
10//! All other read operations bypass BBT entirely via direct SQLite access.
11//!
12//! # Protocol
13//!
14//! BBT exposes a JSON-RPC 2.0 API on `http://localhost:23119/better-bibtex/json-rpc`.
15//! It runs inside Zotero's Electron process, so it requires Zotero to be open.
16
17use anyhow::{Context, Result};
18use serde_json::{Value, json};
19
20/// Blocking client for BBT JSON-RPC API.
21///
22/// Uses a persistent `reqwest::blocking::Client` to reuse TCP connections.
23/// Only instantiated when a BibTeX/bibliography tool is called.
24pub struct BbtRpcClient {
25    client: reqwest::blocking::Client,
26    url: String,
27    next_id: std::cell::Cell<u64>,
28}
29
30impl BbtRpcClient {
31    pub fn new(url: &str) -> Self {
32        Self {
33            client: reqwest::blocking::Client::builder()
34                .timeout(std::time::Duration::from_secs(30))
35                .build()
36                .expect("Failed to build HTTP client"),
37            url: url.into(),
38            next_id: std::cell::Cell::new(1),
39        }
40    }
41
42    /// Make a JSON-RPC call to BBT.
43    fn call(&self, method: &str, params: Value) -> Result<Value> {
44        let id = self.next_id.get();
45        self.next_id.set(id + 1);
46
47        let payload = json!({
48            "jsonrpc": "2.0",
49            "method": method,
50            "params": params,
51            "id": id,
52        });
53
54        let resp = self
55            .client
56            .post(&self.url)
57            .json(&payload)
58            .send()
59            .with_context(|| {
60                "Cannot connect to Zotero. Ensure Zotero is running with Better BibTeX installed."
61            })?;
62
63        let body: Value = resp
64            .json()
65            .with_context(|| "Invalid JSON response from BBT")?;
66
67        if let Some(error) = body.get("error") {
68            let msg = error
69                .get("message")
70                .and_then(|m| m.as_str())
71                .unwrap_or("Unknown BBT error");
72            anyhow::bail!("BBT RPC error: {msg}");
73        }
74
75        Ok(body.get("result").cloned().unwrap_or(Value::Null))
76    }
77
78    /// Export items as BibTeX/BibLaTeX.
79    ///
80    /// `translator`: "Better BibTeX", "Better BibLaTeX", "Better CSL JSON"
81    pub fn export(&self, citekeys: &[&str], translator: &str) -> Result<String> {
82        let result = self.call("item.export", json!([citekeys, translator]))?;
83        match result {
84            Value::String(s) => Ok(s),
85            _ => Ok(result.to_string()),
86        }
87    }
88
89    /// Generate formatted bibliography.
90    ///
91    /// `style`: CSL style URL, e.g. "http://www.zotero.org/styles/apa"
92    pub fn bibliography(&self, citekeys: &[&str], style: &str) -> Result<String> {
93        let result = self.call("item.bibliography", json!([citekeys, {"id": style}]))?;
94        match result {
95            Value::String(s) => Ok(s),
96            _ => Ok(result.to_string()),
97        }
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn client_creates_with_default_url() {
107        let client = BbtRpcClient::new("http://localhost:23119/better-bibtex/json-rpc");
108        assert_eq!(client.url, "http://localhost:23119/better-bibtex/json-rpc");
109    }
110
111    #[test]
112    fn call_to_unreachable_server_returns_error() {
113        let client = BbtRpcClient::new("http://127.0.0.1:1/nonexistent");
114        let result = client.export(&["test2024"], "Better BibTeX");
115        assert!(result.is_err());
116        let err = result.unwrap_err().to_string();
117        assert!(
118            err.contains("Cannot connect") || err.contains("error"),
119            "Unexpected error: {err}"
120        );
121    }
122}