1use async_trait::async_trait;
10
11use crate::adapters::bib_importer::normalize_doi;
12use crate::adapters::zotero_write::{RemoteItem, merge_tags};
13use crate::domain::paper::Paper;
14use crate::error::Result;
15use crate::ports::index_store::IndexStore;
16
17#[async_trait]
20pub trait ZoteroTagSink: Send + Sync {
21 async fn library(&self) -> Result<Vec<RemoteItem>>;
22 async fn write_tags(&self, key: &str, version: i64, tags: &[String]) -> Result<bool>;
24}
25
26#[derive(Debug, Default, PartialEq)]
29pub struct ExportReport {
30 pub updates: Vec<String>,
33 pub unchanged: usize,
35 pub skipped_no_doi: usize,
36 pub skipped_not_in_zotero: usize,
37 pub skipped_ambiguous: usize,
40 pub skipped_conflict: usize,
43}
44
45impl ExportReport {
46 pub fn summary(&self) -> String {
48 let mut line = format!(
49 "{} matched, {} unchanged, {} without DOI, {} not in Zotero, {} ambiguous DOI, {} changed in Zotero",
50 self.updates.len(),
51 self.unchanged,
52 self.skipped_no_doi,
53 self.skipped_not_in_zotero,
54 self.skipped_ambiguous,
55 self.skipped_conflict,
56 );
57 if let Some(first) = self.updates.first() {
58 line.push_str(&format!("\n e.g. {first}"));
59 if self.updates.len() > 1 {
60 line.push_str(&format!("\n ... and {} more", self.updates.len() - 1));
61 }
62 }
63 line
64 }
65}
66
67pub async fn export_tags_to_zotero(
71 store: &dyn IndexStore,
72 sink: &dyn ZoteroTagSink,
73 apply: bool,
74) -> Result<ExportReport> {
75 let remote = sink.library().await?;
76 let mut by_doi: std::collections::HashMap<String, Vec<&RemoteItem>> =
80 std::collections::HashMap::new();
81 for item in &remote {
82 if let Some(doi) = item.doi.as_deref().and_then(normalize_doi) {
83 by_doi.entry(doi).or_default().push(item);
84 }
85 }
86
87 let mut report = ExportReport::default();
88 let mut groups: Vec<(String, Vec<&Paper>)> = Vec::new();
93 let mut group_of: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
94 let papers = store.list_papers(None)?;
95 for paper in &papers {
96 let Some(doi) = paper.doi.as_deref().and_then(normalize_doi) else {
99 report.skipped_no_doi += 1;
100 continue;
101 };
102 if !by_doi.contains_key(&doi) {
103 report.skipped_not_in_zotero += 1;
104 continue;
105 }
106 match group_of.get(&doi) {
107 Some(&idx) => groups[idx].1.push(paper),
108 None => {
109 group_of.insert(doi.clone(), groups.len());
110 groups.push((doi, vec![paper]));
111 }
112 }
113 }
114
115 for (doi, members) in groups {
116 let items = &by_doi[&doi];
117 let &[item] = items.as_slice() else {
118 report.skipped_ambiguous += members.len();
119 continue;
120 };
121 let current: Vec<String> = item
124 .tags
125 .iter()
126 .map(|t| t.trim().to_string())
127 .filter(|t| !t.is_empty())
128 .collect();
129 let mut merged = current.clone();
131 for paper in &members {
132 merged = merge_tags(&merged, &paper.tags);
133 }
134 if merged == current {
135 report.unchanged += members.len();
136 continue;
137 }
138 let added = merged.len() - current.len();
142 let titles: Vec<String> = members
143 .iter()
144 .map(|p| format!("{}: +{added} tag(s)", p.title))
145 .collect();
146 if !apply {
147 report.updates.extend(titles);
148 continue;
149 }
150 if sink.write_tags(&item.key, item.version, &merged).await? {
151 report.updates.extend(titles);
152 } else {
153 report.skipped_conflict += members.len();
157 }
158 }
159 Ok(report)
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use std::sync::Mutex;
166
167 struct FakeSink {
168 items: Vec<RemoteItem>,
169 writes: Mutex<Vec<(String, Vec<String>)>>,
171 conflicts: Vec<String>,
173 }
174
175 impl FakeSink {
176 fn new(items: Vec<RemoteItem>) -> Self {
177 Self {
178 items,
179 writes: Mutex::new(Vec::new()),
180 conflicts: Vec::new(),
181 }
182 }
183 }
184
185 #[async_trait]
186 impl ZoteroTagSink for FakeSink {
187 async fn library(&self) -> Result<Vec<RemoteItem>> {
188 Ok(self.items.clone())
189 }
190 async fn write_tags(&self, key: &str, _version: i64, tags: &[String]) -> Result<bool> {
191 self.writes
192 .lock()
193 .unwrap()
194 .push((key.to_string(), tags.to_vec()));
195 Ok(!self.conflicts.iter().any(|c| c == key))
196 }
197 }
198
199 fn remote(key: &str, doi: &str, tags: &[&str]) -> RemoteItem {
200 RemoteItem {
201 key: key.into(),
202 version: 7,
203 doi: Some(doi.into()),
204 tags: tags.iter().map(|t| (*t).into()).collect(),
205 }
206 }
207
208 fn paper(title: &str, doi: &str, tags: &[&str]) -> Paper {
209 let mut p = Paper::new(title.into());
210 p.doi = Some(doi.into());
211 p.tags = tags.iter().map(|t| (*t).into()).collect();
212 p
213 }
214
215 async fn run(papers: Vec<Paper>, sink: &FakeSink, apply: bool) -> ExportReport {
216 let dir = tempfile::tempdir().unwrap();
217 let store =
218 crate::adapters::sqlite_store::SqliteStore::open(&dir.path().join("t.db")).unwrap();
219 for p in &papers {
220 store.insert_paper(p).unwrap();
221 }
222 export_tags_to_zotero(&store, sink, apply).await.unwrap()
223 }
224
225 #[tokio::test]
226 async fn dry_run_reports_without_writing() {
227 let sink = FakeSink::new(vec![remote("K1", "10.1/a", &["zotero-tag"])]);
228 let report = run(
229 vec![paper("P", "10.1/a", &["zotero-tag", "mine"])],
230 &sink,
231 false,
232 )
233 .await;
234 assert_eq!(report.updates.len(), 1);
235 assert!(
236 sink.writes.lock().unwrap().is_empty(),
237 "dry-run must not write"
238 );
239 }
240
241 #[tokio::test]
242 async fn apply_merges_and_writes_union() {
243 let sink = FakeSink::new(vec![remote("K1", "10.1/a", &["zotero-tag"])]);
244 let report = run(vec![paper("P", "10.1/a", &["mine"])], &sink, true).await;
245 assert_eq!(report.updates.len(), 1);
246 let writes = sink.writes.lock().unwrap();
247 assert_eq!(writes.len(), 1);
248 assert_eq!(writes[0].0, "K1");
249 assert_eq!(writes[0].1, vec!["zotero-tag", "mine"]);
250 }
251
252 #[tokio::test]
253 async fn doi_matching_ignores_case_and_resolver_prefix() {
254 let sink = FakeSink::new(vec![remote("K1", "10.1/A", &[])]);
255 let report = run(
256 vec![paper("P", "https://doi.org/10.1/a", &["mine"])],
257 &sink,
258 true,
259 )
260 .await;
261 assert_eq!(report.updates.len(), 1, "normalized DOIs match");
262 }
263
264 #[tokio::test]
265 async fn unchanged_items_are_counted_not_written() {
266 let sink = FakeSink::new(vec![remote("K1", "10.1/a", &["same"])]);
267 let report = run(vec![paper("P", "10.1/a", &["same"])], &sink, true).await;
268 assert_eq!(report.unchanged, 1);
269 assert!(report.updates.is_empty());
270 assert!(sink.writes.lock().unwrap().is_empty());
271 }
272
273 #[tokio::test]
274 async fn skips_are_counted_by_reason() {
275 let sink = FakeSink::new(vec![remote("K1", "10.1/a", &[])]);
276 let report = run(
277 vec![
278 paper("has doi but absent", "10.1/missing", &[]),
279 paper("no doi at all", "", &[]),
280 ],
281 &sink,
282 false,
283 )
284 .await;
285 assert_eq!(report.skipped_not_in_zotero, 1);
286 assert_eq!(report.skipped_no_doi, 1);
287 }
288
289 #[tokio::test]
290 async fn version_conflicts_skip_and_count() {
291 let mut sink = FakeSink::new(vec![remote("K1", "10.1/a", &["old"])]);
292 sink.conflicts.push("K1".into());
293 let report = run(vec![paper("P", "10.1/a", &["mine"])], &sink, true).await;
294 assert_eq!(report.skipped_conflict, 1);
295 assert!(report.updates.is_empty());
296 }
297
298 #[test]
299 fn summary_names_every_bucket() {
300 let report = ExportReport {
301 updates: vec!["T: +1 tag(s)".into()],
302 unchanged: 2,
303 skipped_no_doi: 3,
304 skipped_not_in_zotero: 4,
305 skipped_ambiguous: 6,
306 skipped_conflict: 5,
307 };
308 let s = report.summary();
309 for needle in [
310 "1 matched",
311 "2 unchanged",
312 "3 without DOI",
313 "4 not in Zotero",
314 "6 ambiguous DOI",
315 "5 changed in Zotero",
316 ] {
317 assert!(s.contains(needle), "summary missing {needle}: {s}");
318 }
319 }
320
321 #[tokio::test]
322 async fn whitespace_only_tag_difference_counts_as_unchanged() {
323 let sink = FakeSink::new(vec![RemoteItem {
326 key: "K1".into(),
327 version: 7,
328 doi: Some("10.1/a".into()),
329 tags: vec![" diamond ".into()],
330 }]);
331 let report = run(vec![paper("P", "10.1/a", &["diamond"])], &sink, true).await;
332 assert_eq!(report.unchanged, 1);
333 assert!(sink.writes.lock().unwrap().is_empty());
334 }
335
336 #[tokio::test]
337 async fn ambiguous_doi_skips_instead_of_guessing() {
338 let sink = FakeSink::new(vec![
339 remote("K1", "10.1/a", &["one"]),
340 remote("K2", "10.1/a", &["two"]),
341 ]);
342 let report = run(vec![paper("P", "10.1/a", &["mine"])], &sink, true).await;
343 assert_eq!(report.skipped_ambiguous, 1);
344 assert!(report.updates.is_empty());
345 assert!(sink.writes.lock().unwrap().is_empty());
346 }
347
348 #[tokio::test]
349 async fn update_count_ignores_whitespace_only_item_tags() {
350 let sink = FakeSink::new(vec![RemoteItem {
353 key: "K1".into(),
354 version: 7,
355 doi: Some("10.1/a".into()),
356 tags: vec![" ".into(), " ".into()],
357 }]);
358 let report = run(vec![paper("P", "10.1/a", &["mine"])], &sink, true).await;
359 assert_eq!(report.updates, vec!["P: +1 tag(s)"]);
360 }
361
362 #[tokio::test]
363 async fn sibling_papers_on_one_doi_contribute_all_their_tags() {
364 let sink = FakeSink::new(vec![remote("K1", "10.1/a", &["old"])]);
368 let report = run(
369 vec![paper("P1", "10.1/a", &["x"]), paper("P2", "10.1/a", &["y"])],
370 &sink,
371 true,
372 )
373 .await;
374 let writes = sink.writes.lock().unwrap();
375 assert_eq!(writes.len(), 1, "one item, one write");
376 let mut written = writes[0].1.clone();
379 written.sort();
380 assert_eq!(written, vec!["old", "x", "y"]);
381 assert_eq!(report.updates.len(), 2, "both papers reported");
382 }
383
384 #[tokio::test]
385 async fn sibling_papers_adding_nothing_are_all_unchanged() {
386 let sink = FakeSink::new(vec![remote("K1", "10.1/a", &["same"])]);
387 let report = run(
388 vec![
389 paper("P1", "10.1/a", &["same"]),
390 paper("P2", "10.1/a", &["same"]),
391 ],
392 &sink,
393 true,
394 )
395 .await;
396 assert_eq!(report.unchanged, 2);
397 assert!(sink.writes.lock().unwrap().is_empty());
398 }
399
400 #[tokio::test]
401 async fn ambiguous_doi_counts_every_sibling_paper() {
402 let sink = FakeSink::new(vec![
403 remote("K1", "10.1/a", &["one"]),
404 remote("K2", "10.1/a", &["two"]),
405 ]);
406 let report = run(
407 vec![paper("P1", "10.1/a", &["x"]), paper("P2", "10.1/a", &["y"])],
408 &sink,
409 false,
410 )
411 .await;
412 assert_eq!(report.skipped_ambiguous, 2);
413 }
414
415 #[tokio::test]
416 async fn conflicting_item_is_not_retried_for_sibling_doi() {
417 let mut sink = FakeSink::new(vec![remote("K1", "10.1/a", &["old"])]);
420 sink.conflicts.push("K1".into());
421 let report = run(
422 vec![
423 paper("P1", "10.1/a", &["mine"]),
424 paper("P2", "10.1/a", &["mine"]),
425 ],
426 &sink,
427 true,
428 )
429 .await;
430 assert_eq!(report.skipped_conflict, 2);
431 assert_eq!(sink.writes.lock().unwrap().len(), 1);
432 }
433}