1use serde_json::{Value, json};
13
14use crate::api::bbt_rpc::BbtRpcClient;
15use crate::api::zotero_web::ZoteroWebClient;
16use crate::protocol::ToolCallResult;
17use crate::server::ServerContext;
18
19fn get_write_client(ctx: &ServerContext) -> Result<ZoteroWebClient, String> {
21 if !ctx.config.writes_enabled {
22 return Err("Write tools disabled. Set ZOTERO_MCP_ENABLE_WRITES=true to enable.".into());
23 }
24 let api_key =
25 ctx.config.zotero_api_key.as_deref().ok_or_else(|| {
26 "Write access requires ZOTERO_API_KEY environment variable.".to_string()
27 })?;
28 if let Some(base_url) = &ctx.config.zotero_api_base_url {
29 Ok(ZoteroWebClient::with_base_url(api_key, base_url))
30 } else {
31 Ok(ZoteroWebClient::new(
32 api_key,
33 &ctx.config.zotero_library_id,
34 &ctx.config.zotero_library_type,
35 ))
36 }
37}
38
39use super::resolve_citekey;
40
41fn resolve_item_key(ctx: &ServerContext, key: &str) -> Result<String, String> {
48 if key.len() == 8 && key.chars().all(|c| c.is_ascii_alphanumeric()) {
50 return Ok(key.to_string());
51 }
52 resolve_citekey(ctx, key)
54}
55
56pub fn zotero_get_bibtex(args: &Value, ctx: &ServerContext) -> ToolCallResult {
65 let citekeys: Vec<&str> = match args.get("citekeys").and_then(|v| v.as_array()) {
66 Some(arr) => arr.iter().filter_map(|v| v.as_str()).collect(),
67 None => match args.get("citekey").and_then(|v| v.as_str()) {
68 Some(ck) => vec![ck],
69 None => return ToolCallResult::error("Missing parameter: citekeys or citekey".into()),
70 },
71 };
72 let format = match args
73 .get("format")
74 .and_then(|v| v.as_str())
75 .unwrap_or("bibtex")
76 {
77 f if f.to_lowercase().contains("biblatex") => "biblatex",
78 _ => "bibtex",
79 };
80
81 let zdb = match ctx.db.zotero() {
82 Ok(db) => db,
83 Err(e) => return ToolCallResult::error(e.to_string()),
84 };
85
86 let mut entries = Vec::new();
87 for citekey in &citekeys {
88 let item_key = match resolve_citekey(ctx, citekey) {
89 Ok(k) => k,
90 Err(e) => return ToolCallResult::error(e),
91 };
92 let item = match zdb.item_by_key(&item_key) {
93 Ok(Some(item)) => item,
94 Ok(None) => return ToolCallResult::error(format!("Item not found: {item_key}")),
95 Err(e) => return ToolCallResult::error(e.to_string()),
96 };
97 let metadata = zdb.item_metadata(item.item_id).unwrap_or_default();
98 entries.push((item, citekey.to_string(), metadata));
99 }
100
101 let result = super::bibtex::items_to_bibtex(&entries, format);
102 ToolCallResult::text(result)
103}
104
105pub fn zotero_get_bibliography(args: &Value, ctx: &ServerContext) -> ToolCallResult {
118 let citekeys: Vec<&str> = match args.get("citekeys").and_then(|v| v.as_array()) {
119 Some(arr) => arr.iter().filter_map(|v| v.as_str()).collect(),
120 None => return ToolCallResult::error("Missing parameter: citekeys".into()),
121 };
122 let style = args
123 .get("style")
124 .and_then(|v| v.as_str())
125 .unwrap_or("http://www.zotero.org/styles/apa");
126
127 if super::bibliography::is_native_style(style) {
129 let zdb = match ctx.db.zotero() {
130 Ok(db) => db,
131 Err(e) => return ToolCallResult::error(e.to_string()),
132 };
133
134 let mut items = Vec::new();
135 for citekey in &citekeys {
136 let item_key = match resolve_citekey(ctx, citekey) {
137 Ok(k) => k,
138 Err(e) => return ToolCallResult::error(e),
139 };
140 let item = match zdb.item_by_key(&item_key) {
141 Ok(Some(item)) => item,
142 Ok(None) => return ToolCallResult::error(format!("Item not found: {item_key}")),
143 Err(e) => return ToolCallResult::error(e.to_string()),
144 };
145 let metadata = zdb.item_metadata(item.item_id).unwrap_or_default();
146 items.push((item, metadata));
147 }
148
149 let result = super::bibliography::format_bibliography_list(&items, style);
150 return ToolCallResult::text(result);
151 }
152
153 let bbt = BbtRpcClient::new(&ctx.config.bbt_url);
155 match bbt.bibliography(&citekeys, style) {
156 Ok(result) => ToolCallResult::text(result),
157 Err(e) => ToolCallResult::error(format!(
158 "Style '{style}' not supported natively. BBT fallback failed (is Zotero running?): {e}"
159 )),
160 }
161}
162
163pub fn zotero_export_bibtex(args: &Value, ctx: &ServerContext) -> ToolCallResult {
165 let format = match args
166 .get("format")
167 .and_then(|v| v.as_str())
168 .unwrap_or("biblatex")
169 {
170 f if f.to_lowercase().contains("biblatex") => "biblatex",
171 _ => "bibtex",
172 };
173
174 let zdb = match ctx.db.zotero() {
175 Ok(db) => db,
176 Err(e) => return ToolCallResult::error(e.to_string()),
177 };
178
179 let item_keys: Vec<(i64, String)> =
181 if let Some(collection_key) = args.get("collection_key").and_then(|v| v.as_str()) {
182 match zdb.collection_items(collection_key, 1000) {
183 Ok(items) => items,
184 Err(e) => return ToolCallResult::error(e.to_string()),
185 }
186 } else if let Some(keys) = args.get("item_keys").and_then(|v| v.as_array()) {
187 keys.iter()
188 .filter_map(|v| v.as_str())
189 .filter_map(|key| {
190 zdb.item_by_key(key)
191 .ok()
192 .flatten()
193 .map(|item| (item.item_id, item.item_key))
194 })
195 .collect()
196 } else {
197 return ToolCallResult::error("Provide either collection_key or item_keys".into());
198 };
199
200 if item_keys.is_empty() {
201 return ToolCallResult::text("No items found to export.".into());
202 }
203
204 let mut entries = Vec::new();
205 for (_item_id, item_key) in &item_keys {
206 if let Ok(Some(item)) = zdb.item_by_key(item_key) {
207 let citekey = ctx
208 .db
209 .citekey_for_item_key(item_key)
210 .unwrap_or_else(|| item_key.clone());
211 let metadata = zdb.item_metadata(item.item_id).unwrap_or_default();
212 entries.push((item, citekey, metadata));
213 }
214 }
215
216 let result = super::bibtex::items_to_bibtex(&entries, format);
217 ToolCallResult::text(result)
218}
219
220pub fn zotero_create_item(args: &Value, ctx: &ServerContext) -> ToolCallResult {
225 let client = match get_write_client(ctx) {
226 Ok(c) => c,
227 Err(e) => return ToolCallResult::error(e),
228 };
229
230 let item_type = match args.get("item_type").and_then(|v| v.as_str()) {
231 Some(t) => t,
232 None => return ToolCallResult::error("Missing parameter: item_type".into()),
233 };
234 let title = match args.get("title").and_then(|v| v.as_str()) {
235 Some(t) => t,
236 None => return ToolCallResult::error("Missing parameter: title".into()),
237 };
238
239 let mut template = match client.item_template(item_type) {
241 Ok(t) => t,
242 Err(e) => return ToolCallResult::error(format!("Failed to get template: {e}")),
243 };
244
245 template["title"] = json!(title);
246
247 if let Some(creators) = args.get("creators").and_then(|v| v.as_array()) {
249 template["creators"] = json!(creators);
250 }
251
252 if let Some(fields) = args.get("fields").and_then(|v| v.as_object()) {
254 for (k, v) in fields {
255 template[k] = v.clone();
256 }
257 }
258
259 if let Some(colls) = args.get("collection_keys").and_then(|v| v.as_array()) {
261 template["collections"] = json!(colls);
262 }
263
264 if let Some(tags) = args.get("tags").and_then(|v| v.as_array()) {
266 let tag_objects: Vec<Value> = tags
267 .iter()
268 .filter_map(|t| t.as_str())
269 .map(|t| json!({"tag": t}))
270 .collect();
271 template["tags"] = json!(tag_objects);
272 }
273
274 match client.create_items(&[template]) {
275 Ok(result) => ToolCallResult::text(format!(
276 "Item created: {}",
277 serde_json::to_string_pretty(&result).unwrap_or_default()
278 )),
279 Err(e) => ToolCallResult::error(format!("Failed to create item: {e}")),
280 }
281}
282
283pub fn zotero_update_item(args: &Value, ctx: &ServerContext) -> ToolCallResult {
284 let client = match get_write_client(ctx) {
285 Ok(c) => c,
286 Err(e) => return ToolCallResult::error(e),
287 };
288
289 let citekey = match args.get("citekey").and_then(|v| v.as_str()) {
290 Some(ck) => ck,
291 None => return ToolCallResult::error("Missing parameter: citekey".into()),
292 };
293
294 let item_key = match resolve_citekey(ctx, citekey) {
295 Ok(k) => k,
296 Err(e) => return ToolCallResult::error(e),
297 };
298
299 let item = match client.get_item(&item_key) {
301 Ok(i) => i,
302 Err(e) => return ToolCallResult::error(format!("Failed to fetch item: {e}")),
303 };
304 let version = item["version"].as_i64().unwrap_or(0) as i32;
305 let mut data = item["data"].clone();
306
307 if let Some(fields) = args.get("fields").and_then(|v| v.as_object()) {
309 for (k, v) in fields {
310 data[k] = v.clone();
311 }
312 }
313
314 if let Some(tags) = args.get("tags").and_then(|v| v.as_array()) {
316 let tag_objects: Vec<Value> = tags
317 .iter()
318 .filter_map(|t| t.as_str())
319 .map(|t| json!({"tag": t}))
320 .collect();
321 data["tags"] = json!(tag_objects);
322 }
323
324 match client.update_item(&item_key, &data, version) {
325 Ok(()) => ToolCallResult::text(format!("Item {citekey} updated.")),
326 Err(e) => ToolCallResult::error(format!("Failed to update: {e}")),
327 }
328}
329
330pub fn zotero_add_tags(args: &Value, ctx: &ServerContext) -> ToolCallResult {
331 let client = match get_write_client(ctx) {
332 Ok(c) => c,
333 Err(e) => return ToolCallResult::error(e),
334 };
335
336 let citekey = match args.get("citekey").and_then(|v| v.as_str()) {
337 Some(ck) => ck,
338 None => return ToolCallResult::error("Missing parameter: citekey".into()),
339 };
340 let new_tags: Vec<&str> = match args.get("tags").and_then(|v| v.as_array()) {
341 Some(arr) => arr.iter().filter_map(|v| v.as_str()).collect(),
342 None => return ToolCallResult::error("Missing parameter: tags".into()),
343 };
344
345 let item_key = match resolve_citekey(ctx, citekey) {
346 Ok(k) => k,
347 Err(e) => return ToolCallResult::error(e),
348 };
349
350 let item = match client.get_item(&item_key) {
351 Ok(i) => i,
352 Err(e) => return ToolCallResult::error(format!("Failed to fetch item: {e}")),
353 };
354 let version = item["version"].as_i64().unwrap_or(0) as i32;
355 let mut data = item["data"].clone();
356
357 let existing: std::collections::HashSet<String> = data["tags"]
359 .as_array()
360 .unwrap_or(&vec![])
361 .iter()
362 .filter_map(|t| t["tag"].as_str().map(String::from))
363 .collect();
364
365 let mut tags: Vec<Value> = existing.iter().map(|t| json!({"tag": t})).collect();
366 for tag in new_tags {
367 if !existing.contains(tag) {
368 tags.push(json!({"tag": tag}));
369 }
370 }
371 data["tags"] = json!(tags);
372
373 match client.update_item(&item_key, &data, version) {
374 Ok(()) => ToolCallResult::text(format!("Tags added to {citekey}.")),
375 Err(e) => ToolCallResult::error(format!("Failed to add tags: {e}")),
376 }
377}
378
379pub fn zotero_add_note(args: &Value, ctx: &ServerContext) -> ToolCallResult {
380 let client = match get_write_client(ctx) {
381 Ok(c) => c,
382 Err(e) => return ToolCallResult::error(e),
383 };
384
385 let citekey = match args.get("citekey").and_then(|v| v.as_str()) {
386 Some(ck) => ck,
387 None => return ToolCallResult::error("Missing parameter: citekey".into()),
388 };
389 let content = match args.get("content").and_then(|v| v.as_str()) {
390 Some(c) => c,
391 None => return ToolCallResult::error("Missing parameter: content".into()),
392 };
393
394 let item_key = match resolve_citekey(ctx, citekey) {
395 Ok(k) => k,
396 Err(e) => return ToolCallResult::error(e),
397 };
398
399 let html = if content.contains('<') {
401 content.to_string() } else {
403 format!("<p>{}</p>", content.replace('\n', "</p><p>"))
404 };
405
406 let note = json!({
407 "itemType": "note",
408 "parentItem": item_key,
409 "note": html,
410 "tags": args.get("tags").and_then(|v| v.as_array())
411 .map(|arr| arr.iter().filter_map(|t| t.as_str()).map(|t| json!({"tag": t})).collect::<Vec<_>>())
412 .unwrap_or_default(),
413 });
414
415 match client.create_items(&[note]) {
416 Ok(_) => ToolCallResult::text(format!("Note added to {citekey}.")),
417 Err(e) => ToolCallResult::error(format!("Failed to add note: {e}")),
418 }
419}
420
421pub fn zotero_create_collection(args: &Value, ctx: &ServerContext) -> ToolCallResult {
422 let client = match get_write_client(ctx) {
423 Ok(c) => c,
424 Err(e) => return ToolCallResult::error(e),
425 };
426
427 let name = match args.get("name").and_then(|v| v.as_str()) {
428 Some(n) => n,
429 None => return ToolCallResult::error("Missing parameter: name".into()),
430 };
431
432 if let Ok(zdb) = ctx.db.zotero()
434 && let Ok(colls) = zdb.collections()
435 && let Some(existing) = colls.iter().find(|c| c.name == name)
436 {
437 return ToolCallResult::text(format!(
438 "Collection '{}' already exists (key: {}).",
439 name, existing.key
440 ));
441 }
442
443 let mut coll = json!({"name": name});
444 if let Some(parent_key) = args.get("parent_key").and_then(|v| v.as_str()) {
445 coll["parentCollection"] = json!(parent_key);
446 }
447
448 match client.create_collections(&[coll]) {
449 Ok(result) => ToolCallResult::text(format!(
450 "Collection '{}' created: {}",
451 name,
452 serde_json::to_string_pretty(&result).unwrap_or_default()
453 )),
454 Err(e) => ToolCallResult::error(format!("Failed to create collection: {e}")),
455 }
456}
457
458pub fn zotero_add_to_collection(args: &Value, ctx: &ServerContext) -> ToolCallResult {
459 let client = match get_write_client(ctx) {
460 Ok(c) => c,
461 Err(e) => return ToolCallResult::error(e),
462 };
463
464 let collection_key = match args.get("collection_key").and_then(|v| v.as_str()) {
465 Some(k) => k,
466 None => return ToolCallResult::error("Missing parameter: collection_key".into()),
467 };
468
469 let item_key = if let Some(ck) = args.get("citekey").and_then(|v| v.as_str()) {
471 match resolve_citekey(ctx, ck) {
472 Ok(k) => k,
473 Err(e) => return ToolCallResult::error(e),
474 }
475 } else if let Some(ik) = args.get("item_key").and_then(|v| v.as_str()) {
476 ik.to_string()
477 } else {
478 return ToolCallResult::error("Missing parameter: citekey or item_key".into());
479 };
480
481 let item = match client.get_item(&item_key) {
482 Ok(i) => i,
483 Err(e) => return ToolCallResult::error(format!("Failed to fetch item: {e}")),
484 };
485 let version = item["version"].as_i64().unwrap_or(0) as i32;
486 let mut data = item["data"].clone();
487
488 let mut collections: Vec<String> = data["collections"]
490 .as_array()
491 .unwrap_or(&vec![])
492 .iter()
493 .filter_map(|c| c.as_str().map(String::from))
494 .collect();
495
496 if !collections.contains(&collection_key.to_string()) {
497 collections.push(collection_key.to_string());
498 data["collections"] = json!(collections);
499 match client.update_item(&item_key, &data, version) {
500 Ok(()) => ToolCallResult::text(format!(
501 "Item {item_key} added to collection {collection_key}."
502 )),
503 Err(e) => ToolCallResult::error(format!("Failed: {e}")),
504 }
505 } else {
506 ToolCallResult::text(format!(
507 "Item {item_key} already in collection {collection_key}."
508 ))
509 }
510}
511
512pub fn zotero_remove_from_collection(args: &Value, ctx: &ServerContext) -> ToolCallResult {
513 let client = match get_write_client(ctx) {
514 Ok(c) => c,
515 Err(e) => return ToolCallResult::error(e),
516 };
517
518 let collection_key = match args.get("collection_key").and_then(|v| v.as_str()) {
519 Some(k) => k,
520 None => return ToolCallResult::error("Missing parameter: collection_key".into()),
521 };
522
523 let item_key = if let Some(ck) = args.get("citekey").and_then(|v| v.as_str()) {
524 match resolve_citekey(ctx, ck) {
525 Ok(k) => k,
526 Err(e) => return ToolCallResult::error(e),
527 }
528 } else if let Some(ik) = args.get("item_key").and_then(|v| v.as_str()) {
529 ik.to_string()
530 } else {
531 return ToolCallResult::error("Missing parameter: citekey or item_key".into());
532 };
533
534 let item = match client.get_item(&item_key) {
535 Ok(i) => i,
536 Err(e) => return ToolCallResult::error(format!("Failed to fetch item: {e}")),
537 };
538 let version = item["version"].as_i64().unwrap_or(0) as i32;
539 let mut data = item["data"].clone();
540
541 let collections: Vec<String> = data["collections"]
542 .as_array()
543 .unwrap_or(&vec![])
544 .iter()
545 .filter_map(|c| c.as_str().map(String::from))
546 .filter(|c| c != collection_key)
547 .collect();
548
549 data["collections"] = json!(collections);
550 match client.update_item(&item_key, &data, version) {
551 Ok(()) => ToolCallResult::text(format!("Item removed from collection {collection_key}.")),
552 Err(e) => ToolCallResult::error(format!("Failed: {e}")),
553 }
554}
555
556pub fn zotero_delete_item(args: &Value, ctx: &ServerContext) -> ToolCallResult {
557 let client = match get_write_client(ctx) {
558 Ok(c) => c,
559 Err(e) => return ToolCallResult::error(e),
560 };
561
562 let item_key = if let Some(ck) = args.get("citekey").and_then(|v| v.as_str()) {
563 match resolve_citekey(ctx, ck) {
564 Ok(k) => k,
565 Err(e) => return ToolCallResult::error(e),
566 }
567 } else if let Some(ik) = args.get("item_key").and_then(|v| v.as_str()) {
568 ik.to_string()
569 } else {
570 return ToolCallResult::error("Missing parameter: citekey or item_key".into());
571 };
572
573 let item = match client.get_item(&item_key) {
574 Ok(i) => i,
575 Err(e) => return ToolCallResult::error(format!("Failed to fetch item: {e}")),
576 };
577 let version = item["version"].as_i64().unwrap_or(0) as i32;
578
579 match client.delete_item(&item_key, version) {
580 Ok(()) => ToolCallResult::text(format!("Item {item_key} deleted permanently.")),
581 Err(e) => ToolCallResult::error(format!("Failed to delete: {e}")),
582 }
583}
584
585pub fn zotero_merge_items(args: &Value, ctx: &ServerContext) -> ToolCallResult {
586 let client = match get_write_client(ctx) {
587 Ok(c) => c,
588 Err(e) => return ToolCallResult::error(e),
589 };
590
591 let keep_ck = match args.get("keep_citekey").and_then(|v| v.as_str()) {
592 Some(ck) => ck,
593 None => return ToolCallResult::error("Missing parameter: keep_citekey".into()),
594 };
595 let delete_ck = match args.get("delete_citekey").and_then(|v| v.as_str()) {
596 Some(ck) => ck,
597 None => return ToolCallResult::error("Missing parameter: delete_citekey".into()),
598 };
599
600 let keep_key = match resolve_citekey(ctx, keep_ck) {
601 Ok(k) => k,
602 Err(e) => return ToolCallResult::error(e),
603 };
604 let delete_key = match resolve_citekey(ctx, delete_ck) {
605 Ok(k) => k,
606 Err(e) => return ToolCallResult::error(e),
607 };
608
609 let keep_item = match client.get_item(&keep_key) {
611 Ok(i) => i,
612 Err(e) => return ToolCallResult::error(format!("Failed to fetch keep item: {e}")),
613 };
614 let delete_item = match client.get_item(&delete_key) {
615 Ok(i) => i,
616 Err(e) => return ToolCallResult::error(format!("Failed to fetch delete item: {e}")),
617 };
618
619 let keep_version = keep_item["version"].as_i64().unwrap_or(0) as i32;
620 let delete_version = delete_item["version"].as_i64().unwrap_or(0) as i32;
621 let mut keep_data = keep_item["data"].clone();
622
623 let mut tags: std::collections::HashSet<String> = keep_data["tags"]
625 .as_array()
626 .unwrap_or(&vec![])
627 .iter()
628 .filter_map(|t| t["tag"].as_str().map(String::from))
629 .collect();
630 if let Some(delete_tags) = delete_item["data"]["tags"].as_array() {
631 for t in delete_tags {
632 if let Some(tag) = t["tag"].as_str() {
633 tags.insert(tag.to_string());
634 }
635 }
636 }
637 keep_data["tags"] = json!(tags.iter().map(|t| json!({"tag": t})).collect::<Vec<_>>());
638
639 let mut colls: std::collections::HashSet<String> = keep_data["collections"]
641 .as_array()
642 .unwrap_or(&vec![])
643 .iter()
644 .filter_map(|c| c.as_str().map(String::from))
645 .collect();
646 if let Some(delete_colls) = delete_item["data"]["collections"].as_array() {
647 for c in delete_colls {
648 if let Some(coll) = c.as_str() {
649 colls.insert(coll.to_string());
650 }
651 }
652 }
653 keep_data["collections"] = json!(colls.into_iter().collect::<Vec<_>>());
654
655 if let Err(e) = client.update_item(&keep_key, &keep_data, keep_version) {
657 return ToolCallResult::error(format!("Failed to update keep item: {e}"));
658 }
659
660 if let Err(e) = client.delete_item(&delete_key, delete_version) {
662 return ToolCallResult::error(format!("Merged but failed to delete duplicate: {e}"));
663 }
664
665 ToolCallResult::text(format!(
666 "Merged {delete_ck} into {keep_ck}. Deleted {delete_ck}."
667 ))
668}
669
670pub fn zotero_attach_pdf(args: &Value, ctx: &ServerContext) -> ToolCallResult {
671 let client = match get_write_client(ctx) {
672 Ok(c) => c,
673 Err(e) => return ToolCallResult::error(e),
674 };
675
676 let raw_key = match args.get("item_key").and_then(|v| v.as_str()) {
677 Some(k) => k,
678 None => return ToolCallResult::error("Missing parameter: item_key".into()),
679 };
680 let pdf_url = match args.get("pdf_url").and_then(|v| v.as_str()) {
681 Some(u) => u,
682 None => return ToolCallResult::error("Missing parameter: pdf_url".into()),
683 };
684 let title = args.get("title").and_then(|v| v.as_str());
685
686 let item_key = match resolve_item_key(ctx, raw_key) {
688 Ok(k) => k,
689 Err(e) => return ToolCallResult::error(format!("Cannot resolve '{raw_key}': {e}")),
690 };
691
692 if let Ok(zdb) = ctx.db.zotero()
694 && let Ok(Some(item)) = zdb.item_by_key(&item_key)
695 && let Ok(atts) = zdb.item_attachments(item.item_id)
696 && atts.iter().any(|a| a.content_type == "application/pdf")
697 {
698 return ToolCallResult::text(format!("Item {item_key} already has a PDF attachment."));
699 }
700
701 if !item_key.chars().all(|c| c.is_ascii_alphanumeric()) {
703 return ToolCallResult::error("Invalid item_key: must be alphanumeric".into());
704 }
705
706 let tmp_dir = std::env::temp_dir();
708 let tmp_file = tmp_dir.join(format!("biblion-{item_key}.pdf"));
709 if let Err(e) = client.download_file(pdf_url, &tmp_file) {
710 return ToolCallResult::error(format!("Download failed: {e}"));
711 }
712
713 let display_title = title.unwrap_or("PDF");
714 match client.attach_file(&item_key, &tmp_file, display_title) {
715 Ok(_) => {
716 let _ = std::fs::remove_file(&tmp_file);
717 ToolCallResult::text(format!("PDF attached to {item_key}."))
718 }
719 Err(e) => {
720 let _ = std::fs::remove_file(&tmp_file);
721 ToolCallResult::error(format!("Failed to attach PDF: {e}"))
722 }
723 }
724}
725
726pub fn zotero_fetch_missing_pdfs(args: &Value, ctx: &ServerContext) -> ToolCallResult {
728 let dry_run = args
729 .get("dry_run")
730 .and_then(|v| v.as_bool())
731 .unwrap_or(false);
732 let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
733 let collection_key = args.get("collection_key").and_then(|v| v.as_str());
734
735 let zdb = match ctx.db.zotero() {
736 Ok(db) => db,
737 Err(e) => return ToolCallResult::error(e.to_string()),
738 };
739
740 let items_to_scan: Vec<(i64, String)> = if let Some(ck) = collection_key {
742 match zdb.collection_items(ck, limit) {
743 Ok(items) => items,
744 Err(e) => return ToolCallResult::error(e.to_string()),
745 }
746 } else {
747 match zdb.recent_items(limit) {
748 Ok(items) => items,
749 Err(e) => return ToolCallResult::error(e.to_string()),
750 }
751 };
752
753 let mut missing: Vec<(String, Option<String>, Option<String>)> = Vec::new(); for (item_id, item_key) in &items_to_scan {
756 let has_pdf = zdb
757 .item_attachments(*item_id)
758 .map(|atts| atts.iter().any(|a| a.content_type == "application/pdf"))
759 .unwrap_or(false);
760
761 if !has_pdf {
762 let metadata = zdb.item_metadata(*item_id).unwrap_or_default();
763 missing.push((
764 item_key.clone(),
765 metadata.get("DOI").cloned(),
766 metadata.get("title").cloned(),
767 ));
768 }
769 }
770
771 if missing.is_empty() {
772 return ToolCallResult::text(format!(
773 "Scanned {} items. All have PDF attachments.",
774 items_to_scan.len()
775 ));
776 }
777
778 let mut output = format!(
779 "Scanned {} items, {} missing PDFs.\n\n",
780 items_to_scan.len(),
781 missing.len()
782 );
783
784 let mut resolved = 0;
785 let mut attached = 0;
786
787 for (item_key, doi, title) in &missing {
788 let result = paper_resolver::resolve_pdf_with_config(
789 doi.as_deref(),
790 None,
791 title.as_deref(),
792 &ctx.config.resolver,
793 );
794
795 let citekey = ctx
796 .db
797 .citekey_for_item_key(item_key)
798 .unwrap_or_else(|| item_key.clone());
799
800 match result {
801 Some(pdf) if pdf.downloadable => {
802 resolved += 1;
803 if dry_run {
804 output.push_str(&format!(
805 "[would attach] {citekey} — {} (via {})\n",
806 pdf.url, pdf.source
807 ));
808 } else {
809 let client = match get_write_client(ctx) {
811 Ok(c) => c,
812 Err(e) => {
813 output.push_str(&format!("[error] {citekey} — {e}\n"));
814 continue;
815 }
816 };
817 let tmp = std::env::temp_dir().join(format!("biblion-{item_key}.pdf"));
818 match client.download_file(&pdf.url, &tmp) {
819 Ok(()) => {
820 match client.attach_file(item_key, &tmp, &citekey) {
821 Ok(_) => {
822 attached += 1;
823 output.push_str(&format!(
824 "[attached] {citekey} — {} (via {})\n",
825 pdf.url, pdf.source
826 ));
827 }
828 Err(e) => {
829 output.push_str(&format!(
830 "[error] {citekey} — attach failed: {e}\n"
831 ));
832 }
833 }
834 let _ = std::fs::remove_file(&tmp);
835 }
836 Err(e) => {
837 output.push_str(&format!("[error] {citekey} — download failed: {e}\n"));
838 }
839 }
840 }
841 }
842 Some(pdf) => {
843 output.push_str(&format!(
844 "[manual] {citekey} — {} (via {}, not downloadable)\n",
845 pdf.url, pdf.source
846 ));
847 }
848 None => {
849 output.push_str(&format!("[not found] {citekey}\n"));
850 }
851 }
852 }
853
854 let not_found = missing.len() - resolved - attached;
855 let manual = resolved - attached;
856 output.push_str(&format!(
857 "\nResolved: {resolved}, Attached: {attached}, Manual: {manual}, Not found: {not_found}"
858 ));
859
860 ToolCallResult::text(output)
861}
862
863#[cfg(test)]
864mod tests {
865 use super::*;
866 use crate::config::{Config, LogLevel};
867 use crate::db::DbPool;
868 use crate::test_helpers::test_zotero_db;
869 use serde_json::json;
870 use wiremock::matchers::{method, path};
871 use wiremock::{Mock, MockServer, ResponseTemplate};
872
873 fn write_ctx_with_base_url(base_url: &str) -> ServerContext {
875 let zdb = test_zotero_db();
876 ServerContext {
877 db: DbPool {
878 zotero: Some(zdb),
879 bbt: None,
880 },
881 config: Config {
882 zotero_sqlite_path: "/tmp/test.sqlite".into(),
883 zotero_storage_path: "/tmp/storage".into(),
884 bbt_migrated_path: "/tmp/bbt".into(),
885 zotero_api_key: Some("test-api-key".into()),
886 zotero_library_id: "12345".into(),
887 zotero_library_type: "user".into(),
888 bbt_url: "http://localhost:23119".into(),
889 log_level: LogLevel::Quiet,
890 writes_enabled: true,
891 resolver: paper_resolver::ResolverConfig::default(),
892 zotero_api_base_url: Some(base_url.into()),
893 },
894 }
895 }
896
897 fn start_mock() -> (tokio::runtime::Runtime, MockServer) {
899 let rt = tokio::runtime::Runtime::new().unwrap();
900 let server = rt.block_on(MockServer::start());
901 (rt, server)
902 }
903
904 fn item_response(key: &str, version: i64) -> ResponseTemplate {
906 ResponseTemplate::new(200).set_body_json(json!({
907 "key": key,
908 "version": version,
909 "data": {
910 "key": key,
911 "version": version,
912 "itemType": "journalArticle",
913 "title": "Hints on Test Data Selection",
914 "tags": [{"tag": "mutation-testing"}, {"tag": "foundational"}],
915 "collections": ["COL00001"],
916 "creators": [],
917 }
918 }))
919 }
920
921 #[test]
926 fn create_item_success() {
927 let (rt, mock_server) = start_mock();
928
929 rt.block_on(async {
930 Mock::given(method("GET"))
931 .and(path("/items/new"))
932 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
933 "itemType": "journalArticle",
934 "title": "",
935 "creators": [],
936 "tags": [],
937 "collections": [],
938 })))
939 .mount(&mock_server)
940 .await;
941
942 Mock::given(method("POST"))
943 .and(path("/items"))
944 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
945 "successful": {"0": {"key": "NEWKEY01", "version": 1}},
946 "unchanged": {},
947 "failed": {}
948 })))
949 .mount(&mock_server)
950 .await;
951 });
952
953 let ctx = write_ctx_with_base_url(&mock_server.uri());
954 let args = json!({
955 "item_type": "journalArticle",
956 "title": "Test Paper",
957 });
958 let result = zotero_create_item(&args, &ctx);
959 assert!(result.is_error.is_none());
960 assert!(result.content[0].text.contains("NEWKEY01"));
961 }
962
963 #[test]
968 fn create_item_missing_title() {
969 let (rt, mock_server) = start_mock();
970 let _ = rt;
971 let ctx = write_ctx_with_base_url(&mock_server.uri());
972 let args = json!({"item_type": "journalArticle"});
973 let result = zotero_create_item(&args, &ctx);
974 assert_eq!(result.is_error, Some(true));
975 assert!(result.content[0].text.contains("Missing parameter: title"));
976 }
977
978 #[test]
979 fn create_item_missing_item_type() {
980 let (rt, mock_server) = start_mock();
981 let _ = rt;
982 let ctx = write_ctx_with_base_url(&mock_server.uri());
983 let args = json!({"title": "Test"});
984 let result = zotero_create_item(&args, &ctx);
985 assert_eq!(result.is_error, Some(true));
986 assert!(
987 result.content[0]
988 .text
989 .contains("Missing parameter: item_type")
990 );
991 }
992
993 #[test]
998 fn update_item_success() {
999 let (rt, mock_server) = start_mock();
1000
1001 rt.block_on(async {
1002 Mock::given(method("GET"))
1003 .and(path("/items/ABC12345"))
1004 .respond_with(item_response("ABC12345", 5))
1005 .mount(&mock_server)
1006 .await;
1007
1008 Mock::given(method("PATCH"))
1009 .and(path("/items/ABC12345"))
1010 .respond_with(ResponseTemplate::new(204))
1011 .mount(&mock_server)
1012 .await;
1013 });
1014
1015 let ctx = write_ctx_with_base_url(&mock_server.uri());
1016 let args = json!({
1017 "citekey": "demilloHintsTestData1978",
1018 "fields": {"title": "Updated Title"},
1019 });
1020 let result = zotero_update_item(&args, &ctx);
1021 assert!(result.is_error.is_none());
1022 assert!(result.content[0].text.contains("updated"));
1023 }
1024
1025 #[test]
1030 fn delete_item_success() {
1031 let (rt, mock_server) = start_mock();
1032
1033 rt.block_on(async {
1034 Mock::given(method("GET"))
1035 .and(path("/items/ABC12345"))
1036 .respond_with(item_response("ABC12345", 5))
1037 .mount(&mock_server)
1038 .await;
1039
1040 Mock::given(method("DELETE"))
1041 .and(path("/items/ABC12345"))
1042 .respond_with(ResponseTemplate::new(204))
1043 .mount(&mock_server)
1044 .await;
1045 });
1046
1047 let ctx = write_ctx_with_base_url(&mock_server.uri());
1048 let args = json!({"citekey": "demilloHintsTestData1978"});
1049 let result = zotero_delete_item(&args, &ctx);
1050 assert!(result.is_error.is_none());
1051 assert!(result.content[0].text.contains("deleted"));
1052 }
1053
1054 #[test]
1059 fn add_tags_success() {
1060 let (rt, mock_server) = start_mock();
1061
1062 rt.block_on(async {
1063 Mock::given(method("GET"))
1064 .and(path("/items/ABC12345"))
1065 .respond_with(item_response("ABC12345", 5))
1066 .mount(&mock_server)
1067 .await;
1068
1069 Mock::given(method("PATCH"))
1070 .and(path("/items/ABC12345"))
1071 .respond_with(ResponseTemplate::new(204))
1072 .mount(&mock_server)
1073 .await;
1074 });
1075
1076 let ctx = write_ctx_with_base_url(&mock_server.uri());
1077 let args = json!({
1078 "citekey": "demilloHintsTestData1978",
1079 "tags": ["new-tag", "mutation-testing"],
1080 });
1081 let result = zotero_add_tags(&args, &ctx);
1082 assert!(result.is_error.is_none());
1083 assert!(result.content[0].text.contains("Tags added"));
1084 }
1085
1086 #[test]
1091 fn add_note_success() {
1092 let (rt, mock_server) = start_mock();
1093
1094 rt.block_on(async {
1095 Mock::given(method("POST"))
1096 .and(path("/items"))
1097 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1098 "successful": {"0": {"key": "NOTE0002", "version": 1}},
1099 "unchanged": {},
1100 "failed": {}
1101 })))
1102 .mount(&mock_server)
1103 .await;
1104 });
1105
1106 let ctx = write_ctx_with_base_url(&mock_server.uri());
1107 let args = json!({
1108 "citekey": "demilloHintsTestData1978",
1109 "content": "This is a test note.",
1110 });
1111 let result = zotero_add_note(&args, &ctx);
1112 assert!(result.is_error.is_none());
1113 assert!(result.content[0].text.contains("Note added"));
1114 }
1115
1116 #[test]
1121 fn create_collection_success() {
1122 let (rt, mock_server) = start_mock();
1123
1124 rt.block_on(async {
1125 Mock::given(method("POST"))
1126 .and(path("/collections"))
1127 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1128 "successful": {"0": {"key": "NEWCOL01", "version": 1}},
1129 "unchanged": {},
1130 "failed": {}
1131 })))
1132 .mount(&mock_server)
1133 .await;
1134 });
1135
1136 let ctx = write_ctx_with_base_url(&mock_server.uri());
1137 let args = json!({"name": "New Collection"});
1138 let result = zotero_create_collection(&args, &ctx);
1139 assert!(result.is_error.is_none());
1140 assert!(result.content[0].text.contains("created"));
1141 }
1142
1143 #[test]
1148 fn create_collection_already_exists() {
1149 let (rt, mock_server) = start_mock();
1150 let _ = rt;
1151
1152 let ctx = write_ctx_with_base_url(&mock_server.uri());
1153 let args = json!({"name": "Mutation Testing"});
1154 let result = zotero_create_collection(&args, &ctx);
1155 assert!(result.is_error.is_none());
1156 assert!(result.content[0].text.contains("already exists"));
1157 assert!(result.content[0].text.contains("COL00001"));
1158 }
1159
1160 #[test]
1165 fn add_to_collection_success() {
1166 let (rt, mock_server) = start_mock();
1167
1168 rt.block_on(async {
1169 Mock::given(method("GET"))
1170 .and(path("/items/ABC12345"))
1171 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1172 "key": "ABC12345",
1173 "version": 5,
1174 "data": {
1175 "key": "ABC12345",
1176 "version": 5,
1177 "itemType": "journalArticle",
1178 "title": "Test",
1179 "tags": [],
1180 "collections": [],
1181 "creators": [],
1182 }
1183 })))
1184 .mount(&mock_server)
1185 .await;
1186
1187 Mock::given(method("PATCH"))
1188 .and(path("/items/ABC12345"))
1189 .respond_with(ResponseTemplate::new(204))
1190 .mount(&mock_server)
1191 .await;
1192 });
1193
1194 let ctx = write_ctx_with_base_url(&mock_server.uri());
1195 let args = json!({
1196 "citekey": "demilloHintsTestData1978",
1197 "collection_key": "NEWCOL01",
1198 });
1199 let result = zotero_add_to_collection(&args, &ctx);
1200 assert!(result.is_error.is_none());
1201 assert!(result.content[0].text.contains("added to collection"));
1202 }
1203
1204 #[test]
1209 fn add_to_collection_already_member() {
1210 let (rt, mock_server) = start_mock();
1211
1212 rt.block_on(async {
1213 Mock::given(method("GET"))
1214 .and(path("/items/ABC12345"))
1215 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1216 "key": "ABC12345",
1217 "version": 5,
1218 "data": {
1219 "key": "ABC12345",
1220 "version": 5,
1221 "itemType": "journalArticle",
1222 "title": "Test",
1223 "tags": [],
1224 "collections": ["COL00001"],
1225 "creators": [],
1226 }
1227 })))
1228 .mount(&mock_server)
1229 .await;
1230 });
1231
1232 let ctx = write_ctx_with_base_url(&mock_server.uri());
1233 let args = json!({
1234 "citekey": "demilloHintsTestData1978",
1235 "collection_key": "COL00001",
1236 });
1237 let result = zotero_add_to_collection(&args, &ctx);
1238 assert!(result.is_error.is_none());
1239 assert!(result.content[0].text.contains("already in collection"));
1240 }
1241
1242 #[test]
1247 fn remove_from_collection_success() {
1248 let (rt, mock_server) = start_mock();
1249
1250 rt.block_on(async {
1251 Mock::given(method("GET"))
1252 .and(path("/items/ABC12345"))
1253 .respond_with(item_response("ABC12345", 5))
1254 .mount(&mock_server)
1255 .await;
1256
1257 Mock::given(method("PATCH"))
1258 .and(path("/items/ABC12345"))
1259 .respond_with(ResponseTemplate::new(204))
1260 .mount(&mock_server)
1261 .await;
1262 });
1263
1264 let ctx = write_ctx_with_base_url(&mock_server.uri());
1265 let args = json!({
1266 "citekey": "demilloHintsTestData1978",
1267 "collection_key": "COL00001",
1268 });
1269 let result = zotero_remove_from_collection(&args, &ctx);
1270 assert!(result.is_error.is_none());
1271 assert!(result.content[0].text.contains("removed from collection"));
1272 }
1273
1274 #[test]
1279 fn merge_items_success() {
1280 let (rt, mock_server) = start_mock();
1281
1282 rt.block_on(async {
1283 Mock::given(method("GET"))
1284 .and(path("/items/ABC12345"))
1285 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1286 "key": "ABC12345",
1287 "version": 5,
1288 "data": {
1289 "key": "ABC12345",
1290 "itemType": "journalArticle",
1291 "title": "Hints on Test Data Selection",
1292 "tags": [{"tag": "mutation-testing"}],
1293 "collections": ["COL00001"],
1294 }
1295 })))
1296 .mount(&mock_server)
1297 .await;
1298
1299 Mock::given(method("GET"))
1300 .and(path("/items/DEF67890"))
1301 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1302 "key": "DEF67890",
1303 "version": 3,
1304 "data": {
1305 "key": "DEF67890",
1306 "itemType": "book",
1307 "title": "The Art of Testing",
1308 "tags": [{"tag": "foundational"}, {"tag": "testing"}],
1309 "collections": ["COL00002"],
1310 }
1311 })))
1312 .mount(&mock_server)
1313 .await;
1314
1315 Mock::given(method("PATCH"))
1316 .and(path("/items/ABC12345"))
1317 .respond_with(ResponseTemplate::new(204))
1318 .mount(&mock_server)
1319 .await;
1320
1321 Mock::given(method("DELETE"))
1322 .and(path("/items/DEF67890"))
1323 .respond_with(ResponseTemplate::new(204))
1324 .mount(&mock_server)
1325 .await;
1326 });
1327
1328 let ctx = write_ctx_with_base_url(&mock_server.uri());
1329 let args = json!({
1330 "keep_citekey": "demilloHintsTestData1978",
1331 "delete_citekey": "artTesting2020",
1332 });
1333 let result = zotero_merge_items(&args, &ctx);
1334 assert!(result.is_error.is_none());
1335 assert!(result.content[0].text.contains("Merged"));
1336 assert!(result.content[0].text.contains("Deleted"));
1337 }
1338
1339 #[test]
1344 fn writes_disabled_returns_error() {
1345 let ctx = crate::test_helpers::test_ctx();
1346 let result = get_write_client(&ctx);
1347 assert!(result.is_err());
1348 assert!(result.unwrap_err().contains("Write tools disabled"));
1349 }
1350
1351 #[test]
1356 fn missing_api_key_returns_error() {
1357 let zdb = test_zotero_db();
1358 let ctx = ServerContext {
1359 db: DbPool {
1360 zotero: Some(zdb),
1361 bbt: None,
1362 },
1363 config: Config {
1364 zotero_sqlite_path: "/tmp/test.sqlite".into(),
1365 zotero_storage_path: "/tmp/storage".into(),
1366 bbt_migrated_path: "/tmp/bbt".into(),
1367 zotero_api_key: None,
1368 zotero_library_id: "12345".into(),
1369 zotero_library_type: "user".into(),
1370 bbt_url: "http://localhost:23119".into(),
1371 log_level: LogLevel::Quiet,
1372 writes_enabled: true,
1373 resolver: paper_resolver::ResolverConfig::default(),
1374 zotero_api_base_url: None,
1375 },
1376 };
1377 let result = get_write_client(&ctx);
1378 assert!(result.is_err());
1379 assert!(result.unwrap_err().contains("ZOTERO_API_KEY"));
1380 }
1381
1382 #[test]
1385 fn resolve_item_key_accepts_8char_alphanumeric() {
1386 let ctx = write_ctx_with_base_url("http://unused");
1387 let result = resolve_item_key(&ctx, "TQPUXSC2");
1389 assert_eq!(result, Ok("TQPUXSC2".into()));
1390 }
1391
1392 #[test]
1393 fn resolve_item_key_accepts_mixed_case_digits() {
1394 let ctx = write_ctx_with_base_url("http://unused");
1395 let result = resolve_item_key(&ctx, "Ab3Cd4Ef");
1396 assert_eq!(result, Ok("Ab3Cd4Ef".into()));
1397 }
1398
1399 #[test]
1400 fn resolve_item_key_short_key_falls_through_to_citekey() {
1401 let ctx = write_ctx_with_base_url("http://unused");
1402 let result = resolve_item_key(&ctx, "ABC");
1404 assert!(result.is_err());
1405 }
1406
1407 #[test]
1408 fn resolve_item_key_long_string_treated_as_citekey() {
1409 let ctx = write_ctx_with_base_url("http://unused");
1410 let result = resolve_item_key(&ctx, "jiaAnalysisSurveyDevelopment2011");
1412 assert!(result.is_err());
1414 }
1415}