1use std::collections::HashMap;
37
38use super::format::extract_year;
39use crate::db::zotero::{Creator, ZoteroItem};
40
41fn 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
58fn 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
73fn 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
88fn 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
101fn 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
114pub 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 let authors = format_authors(&item.creators);
133 if !authors.is_empty() {
134 fields.push(("author".into(), authors));
135 }
136
137 let editors = format_editors(&item.creators);
139 if !editors.is_empty() {
140 fields.push(("editor".into(), editors));
141 }
142
143 fields.push(("title".into(), escape_bibtex(&item.title)));
145
146 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 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 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 if let Some(doi) = &item.doi {
192 fields.push(("doi".into(), doi.clone()));
193 }
194
195 if let Some(url) = &item.url {
197 fields.push(("url".into(), url.clone()));
198 }
199
200 let mut output = format!("@{entry_type}{{{citekey},\n");
202 for (key, value) in &fields {
203 if key == "title" {
204 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
214pub fn items_to_bibtex(
218 items: &[(ZoteroItem, String, HashMap<String, String>)], 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 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}