Skip to main content

scitadel_export/import/
side_effects.rs

1//! Zotero compat — non-row work that follows a successful import
2//! resolution (#134 step 4 + #162):
3//!
4//! - `note={...}` → an unanchored [`Annotation`] (paper-level), with
5//!   the bib's `keywords={a,b,c}` carried as the annotation's tags.
6//!   Author is the import-time `reader`, matching the convention used
7//!   for TUI / MCP annotation writes.
8//! - `keywords=` *without* a `note=` → paper-level tag rows on
9//!   `paper_tags` (#162). Earlier behavior surfaced these as
10//!   `dropped_keywords` under `--verbose` because paper-tags didn't
11//!   exist as first-class data — they do now, so the data is no
12//!   longer dropped on the floor.
13//! - `file={...}` is always surfaced for `--verbose` logging — the
14//!   issue spec deliberately drops these, since paths in someone
15//!   else's Zotero export are meaningless on the importer's machine.
16//!
17//! The alias side effect is unconditional for all non-Rejected
18//! actions: the imported citekey gets recorded on `paper_aliases`
19//! (#134 step 1) so a future re-import resolves via the alias step
20//! of the match cascade.
21
22use scitadel_core::models::{Anchor, Annotation, PaperId, imported_sentence_id};
23
24use super::merge::MergeAction;
25use super::parse::BibEntry;
26
27/// `source` value recorded on `paper_aliases` rows that originate
28/// here — mirrors the constant in `scitadel_db::sqlite::SOURCE_BIBTEX_IMPORT`.
29/// Re-declared instead of imported to keep `scitadel-export` free of a
30/// `scitadel-db` dependency.
31pub const ALIAS_SOURCE: &str = "bibtex-import";
32
33/// `source` value recorded on `paper_tags` rows that originate here —
34/// mirrors `scitadel_db::sqlite::TAG_SOURCE_BIBTEX_IMPORT` for the
35/// same dep-cycle reason as [`ALIAS_SOURCE`]. Same string value: the
36/// audit columns are deliberately uniform across the two tables.
37pub const TAG_SOURCE: &str = "bibtex-import";
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct AliasRecord {
41    pub paper_id: String,
42    pub alias: String,
43    pub source: &'static str,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct TagRecord {
48    pub paper_id: String,
49    pub tag: String,
50    pub source: &'static str,
51}
52
53#[derive(Debug, Clone)]
54pub struct SideEffects {
55    /// Citekey-as-alias to record. `None` only when the merge action
56    /// was `Rejected` (no DB writes happen at all).
57    pub alias: Option<AliasRecord>,
58    /// `note=` carrying user's reading context. Built unanchored
59    /// (paper-level), with `bib.keywords` attached as tags.
60    pub annotation: Option<Annotation>,
61    /// `keywords=` from a note-less entry — written to `paper_tags`
62    /// rather than dropped (#162). Empty when keywords rode along on
63    /// an annotation, when there were none, or when the action was
64    /// `Rejected`.
65    pub paper_tags: Vec<TagRecord>,
66    /// `file=` paths — never imported (paths from a foreign machine
67    /// are meaningless), but surfaced in `--verbose`.
68    pub dropped_file: Option<String>,
69}
70
71impl SideEffects {
72    /// Empty bundle — used when `MergeAction::Rejected` short-circuits
73    /// the side-effect plumbing entirely.
74    pub fn rejected() -> Self {
75        Self {
76            alias: None,
77            annotation: None,
78            paper_tags: vec![],
79            dropped_file: None,
80        }
81    }
82
83    /// True when no DB writes nor user-facing logs follow from this
84    /// import row — the caller can short-circuit per-row work.
85    pub fn is_empty(&self) -> bool {
86        self.alias.is_none()
87            && self.annotation.is_none()
88            && self.paper_tags.is_empty()
89            && self.dropped_file.is_none()
90    }
91}
92
93/// Compute side effects for an import row. `paper_id` is the resolved
94/// paper id (post-match or post-create); `reader` is the import-time
95/// identity used as `Annotation.author` to mirror TUI/MCP conventions.
96pub fn compute(paper_id: &str, reader: &str, bib: &BibEntry, action: MergeAction) -> SideEffects {
97    if action == MergeAction::Rejected {
98        return SideEffects::rejected();
99    }
100
101    let alias = Some(AliasRecord {
102        paper_id: paper_id.to_string(),
103        alias: bib.citekey.clone(),
104        source: ALIAS_SOURCE,
105    });
106
107    // keywords-with-note → annotation tags (existing behavior).
108    // keywords-without-note → paper_tags rows (#162 — the fix).
109    let (annotation, paper_tags) = match bib.note.as_deref() {
110        Some(note) if !note.is_empty() => {
111            // Synthetic `sentence_id` so the resolver recognizes this
112            // as an unanchored import on first open instead of flipping
113            // it to `Orphan` (#158). Hash incorporates citekey + note
114            // content so the same `(citekey, note)` pair always
115            // produces the same id — re-import collapses to no-op.
116            let synthetic = Anchor {
117                sentence_id: Some(imported_sentence_id(&bib.citekey, note)),
118                ..Anchor::default()
119            };
120            let mut a = Annotation::new_root(
121                PaperId::from(paper_id),
122                reader.to_string(),
123                note.to_string(),
124                synthetic,
125            );
126            a.tags.clone_from(&bib.keywords);
127            (Some(a), vec![])
128        }
129        _ => {
130            let tags = bib
131                .keywords
132                .iter()
133                .map(|k| TagRecord {
134                    paper_id: paper_id.to_string(),
135                    tag: k.clone(),
136                    source: TAG_SOURCE,
137                })
138                .collect();
139            (None, tags)
140        }
141    };
142
143    SideEffects {
144        alias,
145        annotation,
146        paper_tags,
147        dropped_file: bib.file.clone(),
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use std::collections::HashMap;
155
156    fn bib(citekey: &str) -> BibEntry {
157        BibEntry {
158            citekey: citekey.into(),
159            title: None,
160            authors: vec![],
161            year: None,
162            doi: None,
163            arxiv_id: None,
164            pubmed_id: None,
165            openalex_id: None,
166            note: None,
167            keywords: vec![],
168            file: None,
169            extra: HashMap::new(),
170        }
171    }
172
173    #[test]
174    fn rejected_action_yields_empty_side_effects() {
175        let mut b = bib("k");
176        b.note = Some("ignored".into());
177        b.keywords = vec!["x".into()];
178        b.file = Some("/p.pdf".into());
179        let se = compute("p1", "lars", &b, MergeAction::Rejected);
180        assert!(se.is_empty());
181        assert!(se.alias.is_none());
182    }
183
184    #[test]
185    fn alias_recorded_for_every_non_rejected_action() {
186        for action in [
187            MergeAction::Created,
188            MergeAction::Updated,
189            MergeAction::Unchanged,
190        ] {
191            let se = compute("p1", "lars", &bib("smith2024"), action);
192            let a = se.alias.expect("alias recorded");
193            assert_eq!(a.paper_id, "p1");
194            assert_eq!(a.alias, "smith2024");
195            assert_eq!(a.source, "bibtex-import");
196        }
197    }
198
199    #[test]
200    fn note_becomes_unanchored_annotation_with_reader_as_author() {
201        let mut b = bib("k");
202        b.note = Some("Read twice; methodology questionable".into());
203        let se = compute("p-x", "lars", &b, MergeAction::Updated);
204        let a = se.annotation.expect("annotation built from note");
205        assert_eq!(a.author, "lars");
206        assert_eq!(a.note, "Read twice; methodology questionable");
207        assert_eq!(a.paper_id.as_str(), "p-x");
208        assert!(a.parent_id.is_none(), "root annotation");
209        // No selectors against paper text — only the synthetic
210        // `sentence_id` marker the resolver uses to short-circuit (#158).
211        assert!(a.anchor.char_range.is_none());
212        assert!(a.anchor.quote.is_none());
213        assert!(a.anchor.prefix.is_none());
214        assert!(a.anchor.suffix.is_none());
215        assert!(a.anchor.is_imported_synthetic());
216    }
217
218    #[test]
219    fn imported_synthetic_sentence_id_is_stable_per_citekey_note() {
220        let mut b1 = bib("smith2024");
221        b1.note = Some("methodology questionable".into());
222        let mut b2 = bib("smith2024");
223        b2.note = Some("methodology questionable".into());
224        let mut b3 = bib("smith2024");
225        b3.note = Some("different note".into());
226        let mut b4 = bib("jones2024");
227        b4.note = Some("methodology questionable".into());
228
229        let a1 = compute("p", "lars", &b1, MergeAction::Updated)
230            .annotation
231            .unwrap();
232        let a2 = compute("p", "lars", &b2, MergeAction::Updated)
233            .annotation
234            .unwrap();
235        let a3 = compute("p", "lars", &b3, MergeAction::Updated)
236            .annotation
237            .unwrap();
238        let a4 = compute("p", "lars", &b4, MergeAction::Updated)
239            .annotation
240            .unwrap();
241
242        assert_eq!(a1.anchor.sentence_id, a2.anchor.sentence_id);
243        assert_ne!(a1.anchor.sentence_id, a3.anchor.sentence_id);
244        assert_ne!(a1.anchor.sentence_id, a4.anchor.sentence_id);
245    }
246
247    #[test]
248    fn keywords_attach_to_annotation_when_note_exists() {
249        let mut b = bib("k");
250        b.note = Some("ok".into());
251        b.keywords = vec!["alpha".into(), "beta".into()];
252        let se = compute("p1", "lars", &b, MergeAction::Updated);
253        let a = se.annotation.unwrap();
254        assert_eq!(a.tags, vec!["alpha", "beta"]);
255        assert!(
256            se.paper_tags.is_empty(),
257            "keywords riding on annotation must not also write paper-tag rows"
258        );
259    }
260
261    #[test]
262    fn keywords_without_note_become_paper_tags() {
263        let mut b = bib("k");
264        b.keywords = vec!["alpha".into(), "beta".into()];
265        let se = compute("p1", "lars", &b, MergeAction::Updated);
266        assert!(se.annotation.is_none());
267        let tags: Vec<&str> = se.paper_tags.iter().map(|t| t.tag.as_str()).collect();
268        assert_eq!(tags, vec!["alpha", "beta"]);
269        for t in &se.paper_tags {
270            assert_eq!(t.paper_id, "p1");
271            assert_eq!(t.source, TAG_SOURCE);
272        }
273    }
274
275    #[test]
276    fn empty_note_string_is_treated_as_no_note() {
277        let mut b = bib("k");
278        b.note = Some(String::new());
279        b.keywords = vec!["x".into()];
280        let se = compute("p1", "lars", &b, MergeAction::Updated);
281        assert!(
282            se.annotation.is_none(),
283            "empty note string must not produce an empty-content annotation"
284        );
285        let tags: Vec<&str> = se.paper_tags.iter().map(|t| t.tag.as_str()).collect();
286        assert_eq!(tags, vec!["x"]);
287    }
288
289    #[test]
290    fn file_field_always_surfaces_under_verbose() {
291        let mut b = bib("k");
292        b.file = Some("/somebody/elses/path.pdf".into());
293        let se = compute("p1", "lars", &b, MergeAction::Created);
294        assert_eq!(se.dropped_file.as_deref(), Some("/somebody/elses/path.pdf"));
295    }
296
297    #[test]
298    fn no_zotero_extras_yields_alias_only() {
299        let se = compute("p1", "lars", &bib("k"), MergeAction::Updated);
300        assert!(se.alias.is_some());
301        assert!(se.annotation.is_none());
302        assert!(se.paper_tags.is_empty());
303        assert!(se.dropped_file.is_none());
304    }
305
306    #[test]
307    fn rejected_action_skips_paper_tags_too() {
308        let mut b = bib("k");
309        b.keywords = vec!["should-not-land".into()];
310        let se = compute("p1", "lars", &b, MergeAction::Rejected);
311        assert!(se.is_empty());
312        assert!(se.paper_tags.is_empty());
313    }
314}