1use scitadel_core::models::{Anchor, Annotation, PaperId, imported_sentence_id};
23
24use super::merge::MergeAction;
25use super::parse::BibEntry;
26
27pub const ALIAS_SOURCE: &str = "bibtex-import";
32
33pub 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 pub alias: Option<AliasRecord>,
58 pub annotation: Option<Annotation>,
61 pub paper_tags: Vec<TagRecord>,
66 pub dropped_file: Option<String>,
69}
70
71impl SideEffects {
72 pub fn rejected() -> Self {
75 Self {
76 alias: None,
77 annotation: None,
78 paper_tags: vec![],
79 dropped_file: None,
80 }
81 }
82
83 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
93pub 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 let (annotation, paper_tags) = match bib.note.as_deref() {
110 Some(note) if !note.is_empty() => {
111 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 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}