1use std::collections::HashMap;
35use std::path::Path;
36
37use anyhow::{Context, Result};
38use rusqlite::Connection;
39
40#[derive(Debug, Clone)]
42pub struct ZoteroItem {
43 pub item_id: i64,
44 pub item_key: String,
45 pub item_type: String,
46 pub title: String,
47 pub date: Option<String>,
48 pub doi: Option<String>,
49 pub url: Option<String>,
50 pub abstract_note: Option<String>,
51 pub creators: Vec<Creator>,
52 pub tags: Vec<String>,
53 pub date_added: String,
54 pub date_modified: String,
55}
56
57#[derive(Debug, Clone)]
58pub struct Creator {
59 pub creator_type: String,
60 pub first_name: Option<String>,
61 pub last_name: String,
62 pub order: i32,
63}
64
65#[derive(Debug, Clone)]
66pub struct Collection {
67 pub collection_id: i64,
68 pub key: String,
69 pub name: String,
70 pub parent_key: Option<String>,
71}
72
73#[derive(Debug, Clone)]
74pub struct Attachment {
75 pub item_key: String,
76 pub content_type: String,
77 pub path: Option<String>,
78 pub title: Option<String>,
79 pub storage_hash: Option<String>,
84}
85
86pub struct ZoteroDb {
88 conn: Connection,
89}
90
91impl ZoteroDb {
92 pub fn conn(&self) -> &Connection {
94 &self.conn
95 }
96
97 #[cfg(test)]
99 pub(crate) fn from_connection(conn: Connection) -> Self {
100 Self { conn }
101 }
102
103 pub fn open(path: &Path) -> Result<Self> {
105 let uri = format!("file:{}?mode=ro", path.display());
106 let conn = Connection::open_with_flags(
107 &uri,
108 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
109 )
110 .with_context(|| format!("Failed to open Zotero database: {}", path.display()))?;
111 Ok(Self { conn })
112 }
113
114 pub fn item_count(&self) -> Result<i64> {
120 let count: i64 = self.conn.query_row(
121 "SELECT COUNT(*) FROM items i
122 WHERE i.libraryID = 1
123 AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
124 AND i.itemTypeID NOT IN (
125 SELECT itemTypeID FROM itemTypes
126 WHERE typeName IN ('attachment', 'note', 'annotation')
127 )",
128 [],
129 |row| row.get(0),
130 )?;
131 Ok(count)
132 }
133
134 pub fn item_by_key(&self, key: &str) -> Result<Option<ZoteroItem>> {
136 let row = self.conn.query_row(
137 "SELECT i.itemID, i.key, it.typeName, i.dateAdded, i.dateModified
138 FROM items i
139 JOIN itemTypes it ON i.itemTypeID = it.itemTypeID
140 WHERE i.key = ?1 AND i.libraryID = 1
141 AND i.itemID NOT IN (SELECT itemID FROM deletedItems)",
142 [key],
143 |row| {
144 Ok((
145 row.get::<_, i64>(0)?,
146 row.get::<_, String>(1)?,
147 row.get::<_, String>(2)?,
148 row.get::<_, String>(3)?,
149 row.get::<_, String>(4)?,
150 ))
151 },
152 );
153 match row {
154 Ok((item_id, item_key, item_type, date_added, date_modified)) => {
155 let metadata = self.item_metadata(item_id)?;
156 let creators = self.item_creators(item_id)?;
157 let tags = self.item_tags(item_id)?;
158 Ok(Some(ZoteroItem {
159 item_id,
160 item_key,
161 item_type,
162 title: metadata.get("title").cloned().unwrap_or_default(),
163 date: metadata.get("date").cloned(),
164 doi: metadata.get("DOI").cloned(),
165 url: metadata.get("url").cloned(),
166 abstract_note: metadata.get("abstractNote").cloned(),
167 creators,
168 tags,
169 date_added,
170 date_modified,
171 }))
172 }
173 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
174 Err(e) => Err(e.into()),
175 }
176 }
177
178 pub fn search_items(&self, query: &str, limit: usize) -> Result<Vec<(i64, String)>> {
183 let escaped = query
185 .replace('\\', "\\\\")
186 .replace('%', "\\%")
187 .replace('_', "\\_");
188 let pattern = format!("%{escaped}%");
189 let mut stmt = self.conn.prepare_cached(
190 "SELECT DISTINCT i.itemID, i.key
191 FROM items i
192 JOIN itemData id ON i.itemID = id.itemID
193 JOIN itemDataValues iv ON id.valueID = iv.valueID
194 WHERE iv.value LIKE ?1 ESCAPE '\\'
195 AND i.libraryID = 1
196 AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
197 AND i.itemTypeID NOT IN (
198 SELECT itemTypeID FROM itemTypes
199 WHERE typeName IN ('attachment', 'note', 'annotation')
200 )
201 LIMIT ?2",
202 )?;
203 let rows = stmt.query_map(rusqlite::params![pattern, limit as i64], |row| {
204 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
205 })?;
206 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
207 }
208
209 pub fn recent_items(&self, limit: usize) -> Result<Vec<(i64, String)>> {
211 let mut stmt = self.conn.prepare_cached(
212 "SELECT i.itemID, i.key
213 FROM items i
214 WHERE i.libraryID = 1
215 AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
216 AND i.itemTypeID NOT IN (
217 SELECT itemTypeID FROM itemTypes
218 WHERE typeName IN ('attachment', 'note', 'annotation')
219 )
220 ORDER BY i.dateModified DESC
221 LIMIT ?1",
222 )?;
223 let rows = stmt.query_map([limit as i64], |row| {
224 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
225 })?;
226 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
227 }
228
229 pub fn item_metadata(&self, item_id: i64) -> Result<HashMap<String, String>> {
237 let mut stmt = self.conn.prepare_cached(
238 "SELECT f.fieldName, iv.value
239 FROM itemData id
240 JOIN fields f ON id.fieldID = f.fieldID
241 JOIN itemDataValues iv ON id.valueID = iv.valueID
242 WHERE id.itemID = ?1",
243 )?;
244 let rows = stmt.query_map([item_id], |row| {
245 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
246 })?;
247 let mut map = HashMap::new();
248 for row in rows {
249 let (k, v) = row?;
250 map.insert(k, v);
251 }
252 Ok(map)
253 }
254
255 pub fn item_creators(&self, item_id: i64) -> Result<Vec<Creator>> {
257 let mut stmt = self.conn.prepare_cached(
258 "SELECT c.firstName, c.lastName, ct.creatorType, ic.orderIndex
259 FROM itemCreators ic
260 JOIN creators c ON ic.creatorID = c.creatorID
261 JOIN creatorTypes ct ON ic.creatorTypeID = ct.creatorTypeID
262 WHERE ic.itemID = ?1
263 ORDER BY ic.orderIndex",
264 )?;
265 let rows = stmt.query_map([item_id], |row| {
266 Ok(Creator {
267 first_name: row.get(0)?,
268 last_name: row.get(1)?,
269 creator_type: row.get(2)?,
270 order: row.get(3)?,
271 })
272 })?;
273 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
274 }
275
276 pub fn item_tags(&self, item_id: i64) -> Result<Vec<String>> {
278 let mut stmt = self.conn.prepare_cached(
279 "SELECT DISTINCT t.name FROM tags t
280 JOIN itemTags it ON t.tagID = it.tagID
281 WHERE it.itemID = ?1",
282 )?;
283 let rows = stmt.query_map([item_id], |row| row.get::<_, String>(0))?;
284 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
285 }
286
287 pub fn collections(&self) -> Result<Vec<Collection>> {
293 let mut stmt = self.conn.prepare_cached(
294 "SELECT c.collectionID, c.key, c.collectionName,
295 pc.key as parentKey
296 FROM collections c
297 LEFT JOIN collections pc ON c.parentCollectionID = pc.collectionID
298 WHERE c.libraryID = 1",
299 )?;
300 let rows = stmt.query_map([], |row| {
301 Ok(Collection {
302 collection_id: row.get(0)?,
303 key: row.get(1)?,
304 name: row.get(2)?,
305 parent_key: row.get(3)?,
306 })
307 })?;
308 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
309 }
310
311 pub fn collection_items(
313 &self,
314 collection_key: &str,
315 limit: usize,
316 ) -> Result<Vec<(i64, String)>> {
317 let mut stmt = self.conn.prepare_cached(
318 "SELECT i.itemID, i.key
319 FROM items i
320 JOIN collectionItems ci ON i.itemID = ci.itemID
321 JOIN collections c ON ci.collectionID = c.collectionID
322 WHERE c.key = ?1 AND i.libraryID = 1
323 AND i.itemID NOT IN (SELECT itemID FROM deletedItems)
324 AND i.itemTypeID NOT IN (
325 SELECT itemTypeID FROM itemTypes
326 WHERE typeName IN ('attachment', 'note', 'annotation')
327 )
328 LIMIT ?2",
329 )?;
330 let rows = stmt.query_map(rusqlite::params![collection_key, limit as i64], |row| {
331 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
332 })?;
333 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
334 }
335
336 pub fn item_attachments(&self, item_id: i64) -> Result<Vec<Attachment>> {
346 let mut stmt = self.conn.prepare_cached(
347 "SELECT i.key, ia.contentType, ia.path,
348 (SELECT iv.value FROM itemData id2
349 JOIN itemDataValues iv ON id2.valueID = iv.valueID
350 JOIN fields f ON id2.fieldID = f.fieldID
351 WHERE id2.itemID = ia.itemID AND f.fieldName = 'title') as title,
352 ia.storageHash
353 FROM itemAttachments ia
354 JOIN items i ON ia.itemID = i.itemID
355 WHERE ia.parentItemID = ?1",
356 )?;
357 let rows = stmt.query_map([item_id], |row| {
358 Ok(Attachment {
359 item_key: row.get(0)?,
360 content_type: row.get::<_, Option<String>>(1)?.unwrap_or_default(),
361 path: row.get(2)?,
362 title: row.get(3)?,
363 storage_hash: row.get(4)?,
364 })
365 })?;
366 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
367 }
368
369 pub fn item_notes(&self, item_id: i64) -> Result<Vec<String>> {
375 let mut stmt = self.conn.prepare_cached(
376 "SELECT in2.note FROM itemNotes in2
377 JOIN items i ON in2.itemID = i.itemID
378 WHERE in2.parentItemID = ?1
379 AND i.itemID NOT IN (SELECT itemID FROM deletedItems)",
380 )?;
381 let rows = stmt.query_map([item_id], |row| row.get::<_, String>(0))?;
382 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
383 }
384
385 pub fn collection_count(&self) -> Result<i64> {
387 let count: i64 = self.conn.query_row(
388 "SELECT COUNT(*) FROM collections WHERE libraryID = 1",
389 [],
390 |row| row.get(0),
391 )?;
392 Ok(count)
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 fn test_zotero_db() -> ZoteroDb {
405 let conn = Connection::open_in_memory().unwrap();
406 conn.execute_batch(
407 "
408 -- Core tables
409 CREATE TABLE libraries (libraryID INTEGER PRIMARY KEY);
410 INSERT INTO libraries VALUES (1);
411
412 CREATE TABLE itemTypes (itemTypeID INTEGER PRIMARY KEY, typeName TEXT);
413 INSERT INTO itemTypes VALUES (1, 'journalArticle');
414 INSERT INTO itemTypes VALUES (2, 'book');
415 INSERT INTO itemTypes VALUES (14, 'attachment');
416 INSERT INTO itemTypes VALUES (15, 'note');
417 INSERT INTO itemTypes VALUES (29, 'annotation');
418
419 CREATE TABLE fields (fieldID INTEGER PRIMARY KEY, fieldName TEXT);
420 INSERT INTO fields VALUES (110, 'title');
421 INSERT INTO fields VALUES (14, 'date');
422 INSERT INTO fields VALUES (26, 'DOI');
423 INSERT INTO fields VALUES (1, 'url');
424 INSERT INTO fields VALUES (90, 'abstractNote');
425
426 CREATE TABLE items (
427 itemID INTEGER PRIMARY KEY, itemTypeID INT, dateAdded TEXT,
428 dateModified TEXT, libraryID INT, key TEXT UNIQUE
429 );
430 INSERT INTO items VALUES (1, 1, '2024-01-01', '2024-06-15', 1, 'ABC12345');
431 INSERT INTO items VALUES (2, 2, '2024-02-01', '2024-05-10', 1, 'DEF67890');
432 INSERT INTO items VALUES (3, 14, '2024-01-01', '2024-01-01', 1, 'ATT00001');
433 INSERT INTO items VALUES (4, 15, '2024-03-01', '2024-03-01', 1, 'NOTE0001');
434
435 CREATE TABLE deletedItems (itemID INTEGER PRIMARY KEY, dateDeleted TEXT);
436
437 CREATE TABLE itemDataValues (valueID INTEGER PRIMARY KEY, value TEXT UNIQUE);
438 INSERT INTO itemDataValues VALUES (1, 'Hints on Test Data Selection');
439 INSERT INTO itemDataValues VALUES (2, '1978');
440 INSERT INTO itemDataValues VALUES (3, '10.1109/C-M.1978.218136');
441 INSERT INTO itemDataValues VALUES (4, 'The Art of Testing');
442 INSERT INTO itemDataValues VALUES (5, '2020');
443 INSERT INTO itemDataValues VALUES (6, 'Abstract about mutation testing');
444
445 CREATE TABLE itemData (itemID INT, fieldID INT, valueID INT, PRIMARY KEY (itemID, fieldID));
446 INSERT INTO itemData VALUES (1, 110, 1); -- title
447 INSERT INTO itemData VALUES (1, 14, 2); -- date
448 INSERT INTO itemData VALUES (1, 26, 3); -- DOI
449 INSERT INTO itemData VALUES (1, 90, 6); -- abstract
450 INSERT INTO itemData VALUES (2, 110, 4); -- title
451 INSERT INTO itemData VALUES (2, 14, 5); -- date
452
453 -- Creators
454 CREATE TABLE creatorTypes (creatorTypeID INTEGER PRIMARY KEY, creatorType TEXT);
455 INSERT INTO creatorTypes VALUES (1, 'author');
456 INSERT INTO creatorTypes VALUES (2, 'editor');
457
458 CREATE TABLE creators (creatorID INTEGER PRIMARY KEY, firstName TEXT, lastName TEXT, fieldMode INT);
459 INSERT INTO creators VALUES (1, 'Richard', 'DeMillo', 0);
460 INSERT INTO creators VALUES (2, 'Richard', 'Lipton', 0);
461
462 CREATE TABLE itemCreators (itemID INT, creatorID INT, creatorTypeID INT, orderIndex INT);
463 INSERT INTO itemCreators VALUES (1, 1, 1, 0);
464 INSERT INTO itemCreators VALUES (1, 2, 1, 1);
465
466 -- Tags
467 CREATE TABLE tags (tagID INTEGER PRIMARY KEY, name TEXT UNIQUE);
468 INSERT INTO tags VALUES (1, 'mutation-testing');
469 INSERT INTO tags VALUES (2, 'foundational');
470
471 CREATE TABLE itemTags (itemID INT, tagID INT, type INT, PRIMARY KEY (itemID, tagID));
472 INSERT INTO itemTags VALUES (1, 1, 0);
473 INSERT INTO itemTags VALUES (1, 2, 0);
474
475 -- Collections
476 CREATE TABLE collections (
477 collectionID INTEGER PRIMARY KEY, collectionName TEXT,
478 parentCollectionID INT, libraryID INT, key TEXT UNIQUE
479 );
480 INSERT INTO collections VALUES (1, 'Mutation Testing', NULL, 1, 'COL00001');
481 INSERT INTO collections VALUES (2, 'Foundational', 1, 1, 'COL00002');
482
483 CREATE TABLE collectionItems (collectionID INT, itemID INT, orderIndex INT);
484 INSERT INTO collectionItems VALUES (1, 1, 0);
485 INSERT INTO collectionItems VALUES (1, 2, 1);
486
487 -- Attachments
488 CREATE TABLE itemAttachments (
489 itemID INT PRIMARY KEY, parentItemID INT,
490 linkMode INT, contentType TEXT, path TEXT,
491 charsetID INT, syncState INT, storageModTime INT,
492 storageHash TEXT, lastProcessedModificationTime INT
493 );
494 INSERT INTO itemAttachments VALUES (3, 1, 1, 'application/pdf', 'storage:DeMillo1978.pdf',
495 NULL, 0, NULL, 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4', NULL);
496
497 -- Notes
498 CREATE TABLE itemNotes (itemID INT PRIMARY KEY, parentItemID INT, note TEXT, title TEXT);
499 INSERT INTO itemNotes VALUES (4, 1, '<p>Great foundational paper on mutation testing.</p>', '');
500 ",
501 )
502 .unwrap();
503 ZoteroDb { conn }
504 }
505
506 #[test]
511 fn item_count_excludes_attachments_and_notes() {
512 let db = test_zotero_db();
513 let count = db.item_count().unwrap();
514 assert_eq!(count, 2); }
516
517 #[test]
518 fn item_by_key_found() {
519 let db = test_zotero_db();
520 let item = db.item_by_key("ABC12345").unwrap().unwrap();
521 assert_eq!(item.title, "Hints on Test Data Selection");
522 assert_eq!(item.item_type, "journalArticle");
523 assert_eq!(item.doi, Some("10.1109/C-M.1978.218136".into()));
524 assert_eq!(item.date, Some("1978".into()));
525 assert_eq!(
526 item.abstract_note,
527 Some("Abstract about mutation testing".into())
528 );
529 }
530
531 #[test]
532 fn item_by_key_not_found() {
533 let db = test_zotero_db();
534 assert!(db.item_by_key("ZZZZZZZZ").unwrap().is_none());
535 }
536
537 #[test]
538 fn item_by_key_creators_ordered() {
539 let db = test_zotero_db();
540 let item = db.item_by_key("ABC12345").unwrap().unwrap();
541 assert_eq!(item.creators.len(), 2);
542 assert_eq!(item.creators[0].last_name, "DeMillo");
543 assert_eq!(item.creators[1].last_name, "Lipton");
544 assert_eq!(item.creators[0].order, 0);
545 assert_eq!(item.creators[1].order, 1);
546 }
547
548 #[test]
549 fn item_by_key_tags() {
550 let db = test_zotero_db();
551 let item = db.item_by_key("ABC12345").unwrap().unwrap();
552 assert_eq!(item.tags.len(), 2);
553 assert!(item.tags.contains(&"mutation-testing".to_string()));
554 assert!(item.tags.contains(&"foundational".to_string()));
555 }
556
557 #[test]
562 fn search_by_title() {
563 let db = test_zotero_db();
564 let results = db.search_items("Hints", 10).unwrap();
565 assert_eq!(results.len(), 1);
566 assert_eq!(results[0].1, "ABC12345");
567 }
568
569 #[test]
570 fn search_by_doi() {
571 let db = test_zotero_db();
572 let results = db.search_items("10.1109", 10).unwrap();
573 assert_eq!(results.len(), 1);
574 }
575
576 #[test]
577 fn search_by_abstract() {
578 let db = test_zotero_db();
579 let results = db.search_items("mutation testing", 10).unwrap();
580 assert_eq!(results.len(), 1);
581 }
582
583 #[test]
584 fn search_no_results() {
585 let db = test_zotero_db();
586 let results = db.search_items("quantum computing", 10).unwrap();
587 assert!(results.is_empty());
588 }
589
590 #[test]
591 fn search_respects_limit() {
592 let db = test_zotero_db();
593 let results = db.search_items("t", 1).unwrap(); assert_eq!(results.len(), 1);
595 }
596
597 #[test]
602 fn recent_items_ordered_by_date_modified() {
603 let db = test_zotero_db();
604 let results = db.recent_items(10).unwrap();
605 assert_eq!(results.len(), 2);
606 assert_eq!(results[0].1, "ABC12345");
608 assert_eq!(results[1].1, "DEF67890");
609 }
610
611 #[test]
616 fn collections_with_hierarchy() {
617 let db = test_zotero_db();
618 let colls = db.collections().unwrap();
619 assert_eq!(colls.len(), 2);
620 let parent = colls.iter().find(|c| c.name == "Mutation Testing").unwrap();
621 assert!(parent.parent_key.is_none());
622 let child = colls.iter().find(|c| c.name == "Foundational").unwrap();
623 assert_eq!(child.parent_key, Some("COL00001".into()));
624 }
625
626 #[test]
627 fn collection_items_found() {
628 let db = test_zotero_db();
629 let items = db.collection_items("COL00001", 10).unwrap();
630 assert_eq!(items.len(), 2);
631 }
632
633 #[test]
634 fn collection_items_not_found() {
635 let db = test_zotero_db();
636 let items = db.collection_items("ZZZZZZZZ", 10).unwrap();
637 assert!(items.is_empty());
638 }
639
640 #[test]
645 fn item_attachments_found() {
646 let db = test_zotero_db();
647 let attachments = db.item_attachments(1).unwrap();
648 assert_eq!(attachments.len(), 1);
649 assert_eq!(attachments[0].content_type, "application/pdf");
650 assert_eq!(attachments[0].path, Some("storage:DeMillo1978.pdf".into()));
651 assert_eq!(
652 attachments[0].storage_hash,
653 Some("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4".into())
654 );
655 }
656
657 #[test]
658 fn item_attachments_empty() {
659 let db = test_zotero_db();
660 let attachments = db.item_attachments(2).unwrap();
661 assert!(attachments.is_empty());
662 }
663
664 #[test]
669 fn item_notes_found() {
670 let db = test_zotero_db();
671 let notes = db.item_notes(1).unwrap();
672 assert_eq!(notes.len(), 1);
673 assert!(notes[0].contains("foundational paper"));
674 }
675
676 #[test]
677 fn item_notes_empty() {
678 let db = test_zotero_db();
679 let notes = db.item_notes(2).unwrap();
680 assert!(notes.is_empty());
681 }
682
683 #[test]
688 fn collection_count() {
689 let db = test_zotero_db();
690 assert_eq!(db.collection_count().unwrap(), 2);
691 }
692}