biblion/tools/
bibtex.rs

1//! Native BibTeX/BibLaTeX export — no BBT dependency.
2//!
3//! # Why native?
4//!
5//! BBT's JSON-RPC export takes ~300ms per call (JavaScript inside Electron).
6//! We can generate equivalent BibTeX in <0.1ms from SQLite data.
7//!
8//! # BibTeX format
9//!
10//! ```bibtex
11//! @article{demilloHintsTestData1978,
12//!   author    = {DeMillo, Richard A. and Lipton, Richard J. and Sayward, Fred G.},
13//!   title     = {Hints on Test Data Selection: Help for the Practicing Programmer},
14//!   journal   = {Computer},
15//!   year      = {1978},
16//!   volume    = {11},
17//!   number    = {4},
18//!   pages     = {34--41},
19//!   doi       = {10.1109/C-M.1978.218136},
20//! }
21//! ```
22//!
23//! # Item type mapping
24//!
25//! | Zotero type | BibTeX type | BibLaTeX type |
26//! |-------------|-------------|---------------|
27//! | journalArticle | @article | @article |
28//! | book | @book | @book |
29//! | bookSection | @incollection | @incollection |
30//! | conferencePaper | @inproceedings | @inproceedings |
31//! | thesis | @phdthesis | @thesis |
32//! | report | @techreport | @report |
33//! | webpage | @misc | @online |
34//! | * (other) | @misc | @misc |
35
36use std::collections::HashMap;
37
38use super::format::extract_year;
39use crate::db::zotero::{Creator, ZoteroItem};
40
41/// Map Zotero item types to BibTeX entry types.
42fn bibtex_type(zotero_type: &str) -> &'static str {
43    match zotero_type {
44        "journalArticle" => "article",
45        "book" => "book",
46        "bookSection" => "incollection",
47        "conferencePaper" => "inproceedings",
48        "thesis" => "phdthesis",
49        "report" => "techreport",
50        "webpage" | "blogPost" | "forumPost" => "misc",
51        "presentation" => "misc",
52        "patent" => "patent",
53        "letter" | "email" => "misc",
54        _ => "misc",
55    }
56}
57
58/// Map Zotero item types to BibLaTeX entry types (more granular).
59fn biblatex_type(zotero_type: &str) -> &'static str {
60    match zotero_type {
61        "journalArticle" => "article",
62        "book" => "book",
63        "bookSection" => "incollection",
64        "conferencePaper" => "inproceedings",
65        "thesis" => "thesis",
66        "report" => "report",
67        "webpage" | "blogPost" => "online",
68        "presentation" => "unpublished",
69        _ => "misc",
70    }
71}
72
73/// Format creators as BibTeX `author` field.
74///
75/// BibTeX author format: `Last1, First1 and Last2, First2`
76fn format_authors(creators: &[Creator]) -> String {
77    creators
78        .iter()
79        .filter(|c| c.creator_type == "author")
80        .map(|c| match &c.first_name {
81            Some(first) if !first.is_empty() => format!("{}, {}", c.last_name, first),
82            _ => c.last_name.clone(),
83        })
84        .collect::<Vec<_>>()
85        .join(" and ")
86}
87
88/// Format creators as BibTeX `editor` field.
89fn format_editors(creators: &[Creator]) -> String {
90    creators
91        .iter()
92        .filter(|c| c.creator_type == "editor")
93        .map(|c| match &c.first_name {
94            Some(first) if !first.is_empty() => format!("{}, {}", c.last_name, first),
95            _ => c.last_name.clone(),
96        })
97        .collect::<Vec<_>>()
98        .join(" and ")
99}
100
101/// Escape special BibTeX characters in a string value.
102fn escape_bibtex(s: &str) -> String {
103    s.replace('&', r"\&")
104        .replace('%', r"\%")
105        .replace('$', r"\$")
106        .replace('#', r"\#")
107        .replace('_', r"\_")
108        .replace('{', r"\{")
109        .replace('}', r"\}")
110        .replace('~', r"\textasciitilde{}")
111        .replace('^', r"\^{}")
112}
113
114/// Generate a BibTeX entry for a single item.
115///
116/// `format`: "bibtex" or "biblatex"
117pub fn item_to_bibtex(
118    item: &ZoteroItem,
119    citekey: &str,
120    metadata: &HashMap<String, String>,
121    format: &str,
122) -> String {
123    let entry_type = if format == "biblatex" {
124        biblatex_type(&item.item_type)
125    } else {
126        bibtex_type(&item.item_type)
127    };
128
129    let mut fields: Vec<(String, String)> = Vec::new();
130
131    // Authors
132    let authors = format_authors(&item.creators);
133    if !authors.is_empty() {
134        fields.push(("author".into(), authors));
135    }
136
137    // Editors
138    let editors = format_editors(&item.creators);
139    if !editors.is_empty() {
140        fields.push(("editor".into(), editors));
141    }
142
143    // Title (double braces to preserve capitalization, special chars escaped)
144    fields.push(("title".into(), escape_bibtex(&item.title)));
145
146    // Standard fields from metadata
147    let field_map = [
148        ("publicationTitle", "journal"),
149        ("bookTitle", "booktitle"),
150        ("volume", "volume"),
151        ("issue", "number"),
152        ("pages", "pages"),
153        ("publisher", "publisher"),
154        ("place", "address"),
155        ("university", "school"),
156        ("conferenceName", "booktitle"),
157        ("series", "series"),
158        ("seriesNumber", "number"),
159        ("ISSN", "issn"),
160        ("ISBN", "isbn"),
161        ("language", "language"),
162        ("abstractNote", "abstract"),
163    ];
164
165    for (zotero_field, bibtex_field) in &field_map {
166        if let Some(value) = metadata.get(*zotero_field)
167            && !value.is_empty()
168        {
169            // Don't duplicate booktitle from conferenceName if bookTitle exists
170            if *bibtex_field == "booktitle"
171                && *zotero_field == "conferenceName"
172                && metadata.contains_key("bookTitle")
173            {
174                continue;
175            }
176            fields.push((bibtex_field.to_string(), escape_bibtex(value)));
177        }
178    }
179
180    // Year (extract from date field)
181    if let Some(date) = &item.date {
182        if let Some(year) = extract_year(date) {
183            fields.push(("year".into(), year));
184        }
185        if format == "biblatex" {
186            fields.push(("date".into(), date.clone()));
187        }
188    }
189
190    // DOI
191    if let Some(doi) = &item.doi {
192        fields.push(("doi".into(), doi.clone()));
193    }
194
195    // URL
196    if let Some(url) = &item.url {
197        fields.push(("url".into(), url.clone()));
198    }
199
200    // Format the entry
201    let mut output = format!("@{entry_type}{{{citekey},\n");
202    for (key, value) in &fields {
203        if key == "title" {
204            // Double braces preserve capitalization: title = {{My Title}},
205            output.push_str(&format!("  {key} = {{{{{value}}}}},\n"));
206        } else {
207            output.push_str(&format!("  {key} = {{{value}}},\n"));
208        }
209    }
210    output.push('}');
211    output
212}
213
214// extract_year is imported from format.rs
215
216/// Generate BibTeX for multiple items.
217pub fn items_to_bibtex(
218    items: &[(ZoteroItem, String, HashMap<String, String>)], // (item, citekey, metadata)
219    format: &str,
220) -> String {
221    items
222        .iter()
223        .map(|(item, citekey, metadata)| item_to_bibtex(item, citekey, metadata, format))
224        .collect::<Vec<_>>()
225        .join("\n\n")
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    fn test_item() -> ZoteroItem {
233        ZoteroItem {
234            item_id: 1,
235            item_key: "ABC12345".into(),
236            item_type: "journalArticle".into(),
237            title: "Hints on Test Data Selection".into(),
238            date: Some("1978".into()),
239            doi: Some("10.1109/C-M.1978.218136".into()),
240            url: None,
241            abstract_note: None,
242            creators: vec![
243                Creator {
244                    creator_type: "author".into(),
245                    first_name: Some("Richard A.".into()),
246                    last_name: "DeMillo".into(),
247                    order: 0,
248                },
249                Creator {
250                    creator_type: "author".into(),
251                    first_name: Some("Richard J.".into()),
252                    last_name: "Lipton".into(),
253                    order: 1,
254                },
255            ],
256            tags: vec![],
257            date_added: "2024-01-01".into(),
258            date_modified: "2024-06-15".into(),
259        }
260    }
261
262    fn test_metadata() -> HashMap<String, String> {
263        let mut m = HashMap::new();
264        m.insert("publicationTitle".into(), "Computer".into());
265        m.insert("volume".into(), "11".into());
266        m.insert("issue".into(), "4".into());
267        m.insert("pages".into(), "34-41".into());
268        m
269    }
270
271    #[test]
272    fn bibtex_entry_format() {
273        let item = test_item();
274        let metadata = test_metadata();
275        let bib = item_to_bibtex(&item, "demilloHintsTestData1978", &metadata, "bibtex");
276
277        assert!(bib.starts_with("@article{demilloHintsTestData1978,"));
278        assert!(bib.contains("author = {DeMillo, Richard A. and Lipton, Richard J.}"));
279        assert!(
280            bib.contains("title = {{Hints on Test Data Selection}},"),
281            "Got: {bib}"
282        );
283        assert!(bib.contains("journal = {Computer}"));
284        assert!(bib.contains("year = {1978}"));
285        assert!(bib.contains("volume = {11}"));
286        assert!(bib.contains("doi = {10.1109/C-M.1978.218136}"));
287        assert!(bib.ends_with('}'));
288    }
289
290    #[test]
291    fn biblatex_uses_different_entry_types() {
292        let mut item = test_item();
293        item.item_type = "conferencePaper".into();
294        let bib = item_to_bibtex(&item, "test2024", &HashMap::new(), "biblatex");
295        assert!(bib.starts_with("@inproceedings{test2024,"));
296    }
297
298    #[test]
299    fn biblatex_thesis_type() {
300        let mut item = test_item();
301        item.item_type = "thesis".into();
302        let bibtex = item_to_bibtex(&item, "t", &HashMap::new(), "bibtex");
303        let biblatex = item_to_bibtex(&item, "t", &HashMap::new(), "biblatex");
304        assert!(bibtex.starts_with("@phdthesis{t,"));
305        assert!(biblatex.starts_with("@thesis{t,"));
306    }
307
308    #[test]
309    fn biblatex_includes_date_field() {
310        let item = test_item();
311        let bib = item_to_bibtex(&item, "test", &HashMap::new(), "biblatex");
312        assert!(bib.contains("date = {1978}"));
313    }
314
315    #[test]
316    fn special_characters_escaped() {
317        let mut item = test_item();
318        item.title = "Testing & Verification: A 100% Approach".into();
319        let bib = item_to_bibtex(&item, "test", &HashMap::new(), "bibtex");
320        assert!(bib.contains(r"Testing \& Verification: A 100\% Approach"));
321    }
322
323    #[test]
324    fn extract_year_simple() {
325        assert_eq!(extract_year("2024"), Some("2024".into()));
326    }
327
328    #[test]
329    fn extract_year_full_date() {
330        assert_eq!(extract_year("2024-01-15"), Some("2024".into()));
331    }
332
333    #[test]
334    fn extract_year_zotero_format() {
335        // Zotero sometimes stores "2011-00-00 2011"
336        assert_eq!(extract_year("2011-00-00 2011"), Some("2011".into()));
337    }
338
339    #[test]
340    fn extract_year_none() {
341        assert_eq!(extract_year("no date"), None);
342    }
343
344    #[test]
345    fn editors_formatted_separately() {
346        let item = ZoteroItem {
347            item_id: 1,
348            item_key: "X".into(),
349            item_type: "bookSection".into(),
350            title: "Chapter".into(),
351            date: None,
352            doi: None,
353            url: None,
354            abstract_note: None,
355            creators: vec![
356                Creator {
357                    creator_type: "author".into(),
358                    first_name: Some("Alice".into()),
359                    last_name: "Author".into(),
360                    order: 0,
361                },
362                Creator {
363                    creator_type: "editor".into(),
364                    first_name: Some("Bob".into()),
365                    last_name: "Editor".into(),
366                    order: 1,
367                },
368            ],
369            tags: vec![],
370            date_added: "2024-01-01".into(),
371            date_modified: "2024-01-01".into(),
372        };
373        let bib = item_to_bibtex(&item, "test", &HashMap::new(), "bibtex");
374        assert!(bib.contains("author = {Author, Alice}"));
375        assert!(bib.contains("editor = {Editor, Bob}"));
376    }
377
378    #[test]
379    fn multiple_items_separated_by_blank_line() {
380        let item = test_item();
381        let entries = vec![
382            (item.clone(), "key1".into(), HashMap::new()),
383            (item, "key2".into(), HashMap::new()),
384        ];
385        let bib = items_to_bibtex(&entries, "bibtex");
386        assert!(bib.contains("}\n\n@"));
387    }
388}