use scitadel_core::models::{Anchor, Annotation, PaperId, imported_sentence_id};
use super::merge::MergeAction;
use super::parse::BibEntry;
pub const ALIAS_SOURCE: &str = "bibtex-import";
pub const TAG_SOURCE: &str = "bibtex-import";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AliasRecord {
pub paper_id: String,
pub alias: String,
pub source: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TagRecord {
pub paper_id: String,
pub tag: String,
pub source: &'static str,
}
#[derive(Debug, Clone)]
pub struct SideEffects {
pub alias: Option<AliasRecord>,
pub annotation: Option<Annotation>,
pub paper_tags: Vec<TagRecord>,
pub dropped_file: Option<String>,
}
impl SideEffects {
pub fn rejected() -> Self {
Self {
alias: None,
annotation: None,
paper_tags: vec![],
dropped_file: None,
}
}
pub fn is_empty(&self) -> bool {
self.alias.is_none()
&& self.annotation.is_none()
&& self.paper_tags.is_empty()
&& self.dropped_file.is_none()
}
}
pub fn compute(paper_id: &str, reader: &str, bib: &BibEntry, action: MergeAction) -> SideEffects {
if action == MergeAction::Rejected {
return SideEffects::rejected();
}
let alias = Some(AliasRecord {
paper_id: paper_id.to_string(),
alias: bib.citekey.clone(),
source: ALIAS_SOURCE,
});
let (annotation, paper_tags) = match bib.note.as_deref() {
Some(note) if !note.is_empty() => {
let synthetic = Anchor {
sentence_id: Some(imported_sentence_id(&bib.citekey, note)),
..Anchor::default()
};
let mut a = Annotation::new_root(
PaperId::from(paper_id),
reader.to_string(),
note.to_string(),
synthetic,
);
a.tags.clone_from(&bib.keywords);
(Some(a), vec![])
}
_ => {
let tags = bib
.keywords
.iter()
.map(|k| TagRecord {
paper_id: paper_id.to_string(),
tag: k.clone(),
source: TAG_SOURCE,
})
.collect();
(None, tags)
}
};
SideEffects {
alias,
annotation,
paper_tags,
dropped_file: bib.file.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn bib(citekey: &str) -> BibEntry {
BibEntry {
citekey: citekey.into(),
title: None,
authors: vec![],
year: None,
doi: None,
arxiv_id: None,
pubmed_id: None,
openalex_id: None,
note: None,
keywords: vec![],
file: None,
extra: HashMap::new(),
}
}
#[test]
fn rejected_action_yields_empty_side_effects() {
let mut b = bib("k");
b.note = Some("ignored".into());
b.keywords = vec!["x".into()];
b.file = Some("/p.pdf".into());
let se = compute("p1", "lars", &b, MergeAction::Rejected);
assert!(se.is_empty());
assert!(se.alias.is_none());
}
#[test]
fn alias_recorded_for_every_non_rejected_action() {
for action in [
MergeAction::Created,
MergeAction::Updated,
MergeAction::Unchanged,
] {
let se = compute("p1", "lars", &bib("smith2024"), action);
let a = se.alias.expect("alias recorded");
assert_eq!(a.paper_id, "p1");
assert_eq!(a.alias, "smith2024");
assert_eq!(a.source, "bibtex-import");
}
}
#[test]
fn note_becomes_unanchored_annotation_with_reader_as_author() {
let mut b = bib("k");
b.note = Some("Read twice; methodology questionable".into());
let se = compute("p-x", "lars", &b, MergeAction::Updated);
let a = se.annotation.expect("annotation built from note");
assert_eq!(a.author, "lars");
assert_eq!(a.note, "Read twice; methodology questionable");
assert_eq!(a.paper_id.as_str(), "p-x");
assert!(a.parent_id.is_none(), "root annotation");
assert!(a.anchor.char_range.is_none());
assert!(a.anchor.quote.is_none());
assert!(a.anchor.prefix.is_none());
assert!(a.anchor.suffix.is_none());
assert!(a.anchor.is_imported_synthetic());
}
#[test]
fn imported_synthetic_sentence_id_is_stable_per_citekey_note() {
let mut b1 = bib("smith2024");
b1.note = Some("methodology questionable".into());
let mut b2 = bib("smith2024");
b2.note = Some("methodology questionable".into());
let mut b3 = bib("smith2024");
b3.note = Some("different note".into());
let mut b4 = bib("jones2024");
b4.note = Some("methodology questionable".into());
let a1 = compute("p", "lars", &b1, MergeAction::Updated)
.annotation
.unwrap();
let a2 = compute("p", "lars", &b2, MergeAction::Updated)
.annotation
.unwrap();
let a3 = compute("p", "lars", &b3, MergeAction::Updated)
.annotation
.unwrap();
let a4 = compute("p", "lars", &b4, MergeAction::Updated)
.annotation
.unwrap();
assert_eq!(a1.anchor.sentence_id, a2.anchor.sentence_id);
assert_ne!(a1.anchor.sentence_id, a3.anchor.sentence_id);
assert_ne!(a1.anchor.sentence_id, a4.anchor.sentence_id);
}
#[test]
fn keywords_attach_to_annotation_when_note_exists() {
let mut b = bib("k");
b.note = Some("ok".into());
b.keywords = vec!["alpha".into(), "beta".into()];
let se = compute("p1", "lars", &b, MergeAction::Updated);
let a = se.annotation.unwrap();
assert_eq!(a.tags, vec!["alpha", "beta"]);
assert!(
se.paper_tags.is_empty(),
"keywords riding on annotation must not also write paper-tag rows"
);
}
#[test]
fn keywords_without_note_become_paper_tags() {
let mut b = bib("k");
b.keywords = vec!["alpha".into(), "beta".into()];
let se = compute("p1", "lars", &b, MergeAction::Updated);
assert!(se.annotation.is_none());
let tags: Vec<&str> = se.paper_tags.iter().map(|t| t.tag.as_str()).collect();
assert_eq!(tags, vec!["alpha", "beta"]);
for t in &se.paper_tags {
assert_eq!(t.paper_id, "p1");
assert_eq!(t.source, TAG_SOURCE);
}
}
#[test]
fn empty_note_string_is_treated_as_no_note() {
let mut b = bib("k");
b.note = Some(String::new());
b.keywords = vec!["x".into()];
let se = compute("p1", "lars", &b, MergeAction::Updated);
assert!(
se.annotation.is_none(),
"empty note string must not produce an empty-content annotation"
);
let tags: Vec<&str> = se.paper_tags.iter().map(|t| t.tag.as_str()).collect();
assert_eq!(tags, vec!["x"]);
}
#[test]
fn file_field_always_surfaces_under_verbose() {
let mut b = bib("k");
b.file = Some("/somebody/elses/path.pdf".into());
let se = compute("p1", "lars", &b, MergeAction::Created);
assert_eq!(se.dropped_file.as_deref(), Some("/somebody/elses/path.pdf"));
}
#[test]
fn no_zotero_extras_yields_alias_only() {
let se = compute("p1", "lars", &bib("k"), MergeAction::Updated);
assert!(se.alias.is_some());
assert!(se.annotation.is_none());
assert!(se.paper_tags.is_empty());
assert!(se.dropped_file.is_none());
}
#[test]
fn rejected_action_skips_paper_tags_too() {
let mut b = bib("k");
b.keywords = vec!["should-not-land".into()];
let se = compute("p1", "lars", &b, MergeAction::Rejected);
assert!(se.is_empty());
assert!(se.paper_tags.is_empty());
}
}