use uuid::Uuid;
use crate::store::hierarchy::Hierarchy;
use crate::timeline::calendar::{Calendar, TimelinePoint};
#[derive(Debug, Clone, PartialEq)]
pub struct TlEvent {
pub id: Uuid,
pub title: String,
pub start_ticks: i64,
pub end_ticks: Option<i64>,
pub linked_paragraphs: Vec<Uuid>,
pub characters: Vec<Uuid>,
pub places: Vec<Uuid>,
}
impl TlEvent {
pub fn span(&self) -> (i64, i64) {
(self.start_ticks, self.end_ticks.unwrap_or(self.start_ticks))
}
pub fn overlaps(&self, other: &TlEvent) -> bool {
let (a0, a1) = self.span();
let (b0, b1) = other.span();
a0 <= b1 && b0 <= a1
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum DateSource {
ExplicitLink(Uuid),
InferredFromNearby(Uuid),
Unknown,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TimelineContext {
pub paragraph_id: Uuid,
pub linked_events: Vec<Uuid>,
pub nearby_events: Vec<Uuid>,
pub effective_date: Option<i64>,
pub date_source: DateSource,
pub effective_season: Option<String>,
}
impl TimelineContext {
pub fn empty(paragraph_id: Uuid) -> Self {
TimelineContext {
paragraph_id,
linked_events: Vec::new(),
nearby_events: Vec::new(),
effective_date: None,
date_source: DateSource::Unknown,
effective_season: None,
}
}
pub fn is_empty(&self) -> bool {
self.linked_events.is_empty() && self.nearby_events.is_empty()
}
}
pub fn gather_events(hierarchy: &Hierarchy) -> Vec<TlEvent> {
let mut out: Vec<TlEvent> = hierarchy
.iter()
.filter_map(|n| {
let ev = n.event.as_ref()?;
Some(TlEvent {
id: n.id,
title: n.title.clone(),
start_ticks: ev.start_ticks,
end_ticks: ev.end_ticks,
linked_paragraphs: n.linked_paragraphs.clone(),
characters: ev.characters.clone(),
places: ev.places.clone(),
})
})
.collect();
out.sort_by_key(|e| e.start_ticks);
out
}
pub fn events_near(events: &[TlEvent], ticks: i64, window: i64) -> Vec<&TlEvent> {
events.iter().filter(|e| (e.start_ticks - ticks).abs() <= window).collect()
}
pub fn events_for_character(events: &[TlEvent], character: Uuid) -> Vec<&TlEvent> {
events.iter().filter(|e| e.characters.contains(&character)).collect()
}
pub fn events_for_place(events: &[TlEvent], place: Uuid) -> Vec<&TlEvent> {
events.iter().filter(|e| e.places.contains(&place)).collect()
}
pub fn build_context(
paragraph_id: Uuid,
events: &[TlEvent],
calendar: &Calendar,
window: i64,
) -> TimelineContext {
let linked: Vec<&TlEvent> =
events.iter().filter(|e| e.linked_paragraphs.contains(¶graph_id)).collect();
if linked.is_empty() {
return TimelineContext::empty(paragraph_id);
}
let anchor = linked.iter().min_by_key(|e| e.start_ticks).unwrap();
let effective_date = Some(anchor.start_ticks);
let date_source = DateSource::ExplicitLink(anchor.id);
let effective_season = calendar.season_for(TimelinePoint::from_ticks(anchor.start_ticks));
let linked_ids: std::collections::HashSet<Uuid> = linked.iter().map(|e| e.id).collect();
let nearby_events = events_near(events, anchor.start_ticks, window)
.into_iter()
.filter(|e| !linked_ids.contains(&e.id))
.map(|e| e.id)
.collect();
TimelineContext {
paragraph_id,
linked_events: linked.iter().map(|e| e.id).collect(),
nearby_events,
effective_date,
date_source,
effective_season,
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CoLocationConflict {
pub character: Uuid,
pub event_a: Uuid,
pub event_b: Uuid,
pub title_a: String,
pub title_b: String,
pub place_a: Uuid,
pub place_b: Uuid,
}
pub fn co_location_conflicts(events: &[TlEvent]) -> Vec<CoLocationConflict> {
let mut characters: std::collections::BTreeSet<Uuid> = std::collections::BTreeSet::new();
for e in events {
characters.extend(e.characters.iter().copied());
}
let mut out = Vec::new();
for ch in characters {
let evs: Vec<&TlEvent> = events.iter().filter(|e| e.characters.contains(&ch)).collect();
for i in 0..evs.len() {
for j in (i + 1)..evs.len() {
let (a, b) = (evs[i], evs[j]);
if !a.overlaps(b) {
continue;
}
if a.places.iter().any(|p| b.places.contains(p)) {
continue;
}
if let (Some(pa), Some(pb)) = (a.places.first(), b.places.first()) {
out.push(CoLocationConflict {
character: ch,
event_a: a.id,
event_b: b.id,
title_a: a.title.clone(),
title_b: b.title.clone(),
place_a: *pa,
place_b: *pb,
});
}
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::timeline::calendar::{Calendar, CalendarConfig};
fn gregorian() -> Calendar {
Calendar::from_config(CalendarConfig { preset: "gregorian".into(), ..Default::default() })
}
fn ev(id: Uuid, t: i64, linked: &[Uuid], chars: &[Uuid], places: &[Uuid]) -> TlEvent {
TlEvent {
id,
title: "e".into(),
start_ticks: t,
end_ticks: None,
linked_paragraphs: linked.to_vec(),
characters: chars.to_vec(),
places: places.to_vec(),
}
}
#[test]
fn build_context_links_date_and_season() {
let cal = gregorian();
let para = Uuid::new_v4();
let day = cal.ticks_per("day").unwrap_or(1);
let month = cal.ticks_per("month").unwrap_or(day * 30);
let summer_tick = 6 * month;
let near = Uuid::new_v4();
let far = Uuid::new_v4();
let events = vec![
ev(Uuid::new_v4(), summer_tick, &[para], &[], &[]),
ev(near, summer_tick + month, &[], &[], &[]), ev(far, summer_tick + month * 11, &[], &[], &[]), ];
let ctx = build_context(para, &events, &cal, 90 * day);
assert_eq!(ctx.linked_events.len(), 1);
assert_eq!(ctx.effective_date, Some(summer_tick));
assert_eq!(ctx.effective_season.as_deref(), Some("summer"));
assert!(matches!(ctx.date_source, DateSource::ExplicitLink(_)));
assert_eq!(ctx.nearby_events, vec![near], "the ~1-month event is nearby, the ~11-month one isn't");
}
#[test]
fn no_link_gives_empty_context() {
let cal = gregorian();
let para = Uuid::new_v4();
let events = vec![ev(Uuid::new_v4(), 100, &[Uuid::new_v4()], &[], &[])];
let ctx = build_context(para, &events, &cal, 1000);
assert!(ctx.is_empty());
assert_eq!(ctx.date_source, DateSource::Unknown);
}
#[test]
fn character_and_place_lookups() {
let mara = Uuid::new_v4();
let velmaril = Uuid::new_v4();
let a = ev(Uuid::new_v4(), 10, &[], &[mara], &[velmaril]);
let b = ev(Uuid::new_v4(), 20, &[], &[], &[]);
let events = vec![a.clone(), b];
assert_eq!(events_for_character(&events, mara), vec![&events[0]]);
assert_eq!(events_for_place(&events, velmaril), vec![&events[0]]);
assert!(events_for_character(&events, Uuid::new_v4()).is_empty());
}
#[test]
fn co_location_flags_one_character_two_places() {
let mara = Uuid::new_v4();
let velmaril = Uuid::new_v4();
let korthun = Uuid::new_v4();
let a = TlEvent { end_ticks: Some(20), ..ev(Uuid::new_v4(), 10, &[], &[mara], &[velmaril]) };
let b = ev(Uuid::new_v4(), 15, &[], &[mara], &[korthun]); let c = ev(Uuid::new_v4(), 900, &[], &[mara], &[korthun]);
let conflicts = co_location_conflicts(&[a, b, c]);
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].character, mara);
assert_eq!(conflicts[0].place_a, velmaril);
assert_eq!(conflicts[0].place_b, korthun);
}
#[test]
fn co_location_ignores_shared_place_and_no_place() {
let mara = Uuid::new_v4();
let velmaril = Uuid::new_v4();
let a = TlEvent { end_ticks: Some(20), ..ev(Uuid::new_v4(), 10, &[], &[mara], &[velmaril]) };
let b = ev(Uuid::new_v4(), 15, &[], &[mara], &[velmaril]);
assert!(co_location_conflicts(&[a, b]).is_empty());
let c = TlEvent { end_ticks: Some(20), ..ev(Uuid::new_v4(), 10, &[], &[mara], &[velmaril]) };
let d = ev(Uuid::new_v4(), 15, &[], &[mara], &[]);
assert!(co_location_conflicts(&[c, d]).is_empty());
}
#[test]
fn overlap_detection() {
let a = TlEvent { end_ticks: Some(20), ..ev(Uuid::nil(), 10, &[], &[], &[]) };
let b = ev(Uuid::nil(), 15, &[], &[], &[]); let c = ev(Uuid::nil(), 50, &[], &[], &[]); assert!(a.overlaps(&b));
assert!(!a.overlaps(&c));
}
}