use std::collections::HashMap;
use uuid::Uuid;
use super::{ContinuityFinding, Severity};
use crate::project::ProjectLayout;
use crate::store::hierarchy::Hierarchy;
use crate::store::node::{Node, NodeKind};
#[derive(Debug, Clone)]
pub(crate) struct EntityIntro {
pub name: String,
pub intro_chapter: u32,
pub intro_pos: usize,
}
#[derive(Debug, Clone)]
pub(crate) struct Mention {
pub pos: usize,
pub chapter: u32,
pub anchor: Uuid,
pub text_lc: String,
}
fn chap_label(n: u32) -> String {
if n == 0 { "the opening".to_string() } else { format!("ch. {n}") }
}
pub(crate) fn referenced_before_introduced(
entities: &[EntityIntro],
mentions: &[Mention],
tolerance_chapters: u32,
) -> Vec<ContinuityFinding> {
let mut ordered: Vec<&Mention> = mentions.iter().collect();
ordered.sort_by_key(|m| m.pos);
let mut out = Vec::new();
for e in entities {
let name_lc = e.name.to_lowercase();
let Some(first) = ordered
.iter()
.find(|m| crate::drift::mentions(&m.text_lc, &name_lc))
else {
continue;
};
let earlier = first.pos < e.intro_pos
&& e.intro_chapter.saturating_sub(first.chapter) > tolerance_chapters;
if !earlier {
continue;
}
let entities_v = vec![e.name.clone()];
let chapter = first.chapter;
out.push(ContinuityFinding {
kind: "introduce",
severity: Severity::Warning,
chapter,
anchor: Some(first.anchor),
dedup_key: ContinuityFinding::make_dedup_key("introduce", &entities_v, chapter),
entities: entities_v,
message: format!(
"'{}' is referenced in {} but not introduced until {}.",
e.name,
chap_label(first.chapter),
chap_label(e.intro_chapter),
),
source: "introduce",
});
}
out
}
fn under_system_book(h: &Hierarchy, node: &Node) -> bool {
if node.kind == NodeKind::Book && node.system_tag.is_some() {
return true;
}
h.ancestors(node)
.iter()
.any(|a| a.kind == NodeKind::Book && a.system_tag.is_some())
}
fn para_text_lc(layout: &ProjectLayout, node: &Node) -> Option<String> {
if node.content_type.as_deref() == Some("jinja") {
return None;
}
let rel = node.file.as_ref()?;
let raw = std::fs::read_to_string(layout.root.join(rel)).ok()?;
Some(crate::audiobook::typst_to_plain(&raw).to_lowercase())
}
pub(super) fn roster(h: &Hierarchy, system_tag: &str) -> Vec<(Uuid, String)> {
let Some(book) = h
.iter()
.find(|n| n.kind == NodeKind::Book && n.system_tag.as_deref() == Some(system_tag))
else {
return Vec::new();
};
h.children_of(Some(book.id))
.iter()
.filter_map(|n| {
let t = n.title.trim();
(!t.is_empty()).then(|| (n.id, t.to_string()))
})
.collect()
}
pub(crate) fn scan(
layout: &ProjectLayout,
h: &Hierarchy,
tolerance_chapters: u32,
) -> Vec<ContinuityFinding> {
let mut names: HashMap<Uuid, String> = HashMap::new();
for (id, name) in roster(h, crate::store::SYSTEM_TAG_CHARACTERS)
.into_iter()
.chain(roster(h, crate::store::SYSTEM_TAG_PLACES))
{
names.insert(id, name);
}
if names.is_empty() {
return Vec::new();
}
let mut mentions: Vec<Mention> = Vec::new();
let mut narrative_pos: HashMap<Uuid, (usize, u32)> = HashMap::new();
let mut pos = 0usize;
let mut chapter = 0u32;
for (node, _depth) in h.flatten() {
if under_system_book(h, node) {
continue;
}
match node.kind {
NodeKind::Chapter => chapter += 1,
NodeKind::Paragraph => {
let this = pos;
pos += 1;
narrative_pos.insert(node.id, (this, chapter));
if let Some(text_lc) = para_text_lc(layout, node) {
mentions.push(Mention { pos: this, chapter, anchor: node.id, text_lc });
}
}
_ => {}
}
}
let mut intro: HashMap<Uuid, (usize, u32)> = HashMap::new();
for ev_node in h.iter() {
let Some(ev) = &ev_node.event else { continue };
let involved: Vec<Uuid> =
ev.characters.iter().chain(ev.places.iter()).copied().collect();
if involved.is_empty() {
continue;
}
let mut anchors: Vec<Uuid> = ev_node.linked_paragraphs.clone();
anchors.push(ev_node.id); for anchor in anchors {
let Some(&(apos, achap)) = narrative_pos.get(&anchor) else { continue };
for ent in &involved {
let slot = intro.entry(*ent).or_insert((apos, achap));
if apos < slot.0 {
*slot = (apos, achap);
}
}
}
}
let entities: Vec<EntityIntro> = names
.iter()
.filter_map(|(id, name)| {
intro.get(id).map(|&(intro_pos, intro_chapter)| EntityIntro {
name: name.clone(),
intro_chapter,
intro_pos,
})
})
.collect();
referenced_before_introduced(&entities, &mentions, tolerance_chapters)
}
#[cfg(test)]
mod tests {
use super::*;
fn m(pos: usize, chapter: u32, text: &str) -> Mention {
Mention { pos, chapter, anchor: Uuid::now_v7(), text_lc: text.to_lowercase() }
}
fn intro(name: &str, chapter: u32, pos: usize) -> EntityIntro {
EntityIntro { name: name.to_string(), intro_chapter: chapter, intro_pos: pos }
}
#[test]
fn flags_reference_before_introduction() {
let mentions = vec![
m(3, 2, "A boat crossed; Aldous the ferryman was spoken of."),
m(20, 5, "Aldous finally stepped from the mist."),
];
let entities = vec![intro("Aldous", 5, 20)];
let out = referenced_before_introduced(&entities, &mentions, 0);
assert_eq!(out.len(), 1);
assert_eq!(out[0].kind, "introduce");
assert_eq!(out[0].severity, Severity::Warning);
assert_eq!(out[0].chapter, 2, "anchored at the early reference");
assert_eq!(out[0].anchor, Some(mentions[0].anchor));
assert_eq!(out[0].entities, vec!["Aldous".to_string()]);
}
#[test]
fn introduced_then_mentioned_is_clean() {
let mentions = vec![
m(5, 3, "Mara opened the door."),
m(9, 4, "Mara remembered the door."),
];
let entities = vec![intro("Mara", 3, 5)];
assert!(referenced_before_introduced(&entities, &mentions, 0).is_empty());
}
#[test]
fn same_chapter_foreshadow_is_not_flagged() {
let mentions = vec![
m(4, 3, "Someone mentioned Joren."),
m(6, 3, "Joren arrived."),
];
let entities = vec![intro("Joren", 3, 6)];
assert!(referenced_before_introduced(&entities, &mentions, 0).is_empty());
}
#[test]
fn tolerance_suppresses_one_chapter_early() {
let mentions = vec![m(4, 4, "Nadia was expected."), m(8, 5, "Nadia entered.")];
let entities = vec![intro("Nadia", 5, 8)];
assert_eq!(referenced_before_introduced(&entities, &mentions, 0).len(), 1);
assert!(referenced_before_introduced(&entities, &mentions, 1).is_empty());
}
#[test]
fn russian_names_match() {
let mentions = vec![
m(2, 1, "В деревне про Алдоус говорили ещё до его прихода."),
m(30, 6, "Алдоус наконец вышел из тумана."),
];
let entities = vec![intro("Алдоус", 6, 30)];
let out = referenced_before_introduced(&entities, &mentions, 0);
assert_eq!(out.len(), 1);
assert_eq!(out[0].chapter, 1);
}
}