use crate::dates::GedcomDate;
use crate::names::PersonalName;
use crate::{Node, tags};
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
#[non_exhaustive]
pub struct StructureView {
pub path: Vec<usize>,
pub level: u16,
pub tag: String,
pub label: String,
#[cfg_attr(
feature = "ts",
ts(type = r#""record" | "event" | "attribute" | "structure" | "unknown""#)
)]
pub kind: &'static str,
pub value: String,
pub pointer: Option<String>,
pub extension: bool,
pub children: Vec<Self>,
}
impl StructureView {
#[must_use]
pub fn from_node(node: &Node, level: u16, path: Vec<usize>) -> Self {
let children = node
.children
.iter()
.enumerate()
.filter(|(_, child)| !matches!(child.tag.as_str(), "CONT" | "CONC"))
.map(|(index, child)| {
let mut child_path = path.clone();
child_path.push(index);
Self::from_node(child, level + 1, child_path)
})
.collect();
Self {
path,
level,
tag: node.tag.clone(),
label: tags::display_label(&node.tag).to_owned(),
kind: kind_name(tags::kind(&node.tag, level)),
value: node.logical_value(),
pointer: node.pointer().map(str::to_owned),
extension: tags::is_extension(&node.tag),
children,
}
}
}
const fn kind_name(kind: tags::TagKind) -> &'static str {
match kind {
tags::TagKind::Record => "record",
tags::TagKind::Event => "event",
tags::TagKind::Attribute => "attribute",
tags::TagKind::Structure => "structure",
tags::TagKind::Unknown => "unknown",
}
}
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
#[non_exhaustive]
pub struct RecordView {
pub xref: Option<String>,
pub tag: String,
pub label: String,
pub title: String,
pub structures: Vec<StructureView>,
pub raw: String,
}
impl RecordView {
#[must_use]
pub fn from_node(record: &Node) -> Self {
let structures = record
.children
.iter()
.enumerate()
.filter(|(_, child)| !matches!(child.tag.as_str(), "CONT" | "CONC"))
.map(|(index, child)| StructureView::from_node(child, 1, vec![index]))
.collect();
Self {
xref: record.xref.clone(),
tag: record.tag.clone(),
label: tags::display_label(&record.tag).to_owned(),
title: record_title(record),
structures,
raw: raw_lines(record),
}
}
}
#[must_use]
pub fn raw_lines(record: &Node) -> String {
let mut document = crate::Document {
records: vec![record.clone()],
};
let text = document.to_text();
document.records.clear();
text
}
#[must_use]
pub fn record_title(record: &Node) -> String {
let candidate = match record.tag.as_str() {
"INDI" => record
.first("NAME")
.map(|node| PersonalName::from_node(node).display()),
"FAM" => None,
"SOUR" => record.value_of("TITL").or_else(|| record.value_of("ABBR")),
"REPO" | "SUBM" => record.value_of("NAME"),
"OBJE" => record
.first("FILE")
.and_then(|file| file.value_of("TITL"))
.or_else(|| record.value_of("FILE")),
_ => Some(record.logical_value()),
};
let title = candidate
.map(|text| text.trim().replace('\n', " "))
.filter(|text| !text.is_empty())
.unwrap_or_else(|| tags::display_label(&record.tag).to_owned());
truncate_heading(title)
}
#[must_use]
pub fn truncate_heading(title: String) -> String {
if title.chars().count() > 90 {
let short: String = title.chars().take(87).collect();
format!("{short}…")
} else {
title
}
}
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
#[non_exhaustive]
pub struct EventView {
pub tag: String,
pub label: String,
pub date: Option<String>,
pub date_display: Option<String>,
pub year: Option<i32>,
pub sort_key: Option<i64>,
pub place: Option<String>,
pub age: Option<String>,
pub cause: Option<String>,
pub attribute: bool,
pub value: Option<String>,
pub citations: Vec<String>,
pub path: Vec<usize>,
}
impl EventView {
#[must_use]
pub fn from_node(node: &Node, path: Vec<usize>) -> Self {
let date = node.value_of("DATE");
let parsed = date.as_deref().map(GedcomDate::parse);
let described = node
.value_of("TYPE")
.filter(|value| !value.trim().is_empty());
let label = match (node.tag.as_str(), described) {
(_, Some(kind)) if matches!(node.tag.as_str(), "EVEN" | "FACT") => kind,
_ => tags::display_label(&node.tag).to_owned(),
};
Self {
tag: node.tag.clone(),
label,
date_display: parsed.as_ref().map(GedcomDate::display),
year: parsed.as_ref().and_then(GedcomDate::year_hint),
sort_key: parsed.as_ref().and_then(GedcomDate::sort_key),
date,
place: node.value_of("PLAC"),
age: node.value_of("AGE"),
cause: node.value_of("CAUS"),
attribute: tags::is_attribute(&node.tag),
value: node
.value
.clone()
.filter(|value| !value.trim().is_empty() && value != "Y"),
citations: node
.all("SOUR")
.filter_map(|source| source.pointer().map(str::to_owned))
.collect(),
path,
}
}
}
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
#[non_exhaustive]
pub struct IndividualView {
pub xref: String,
pub names: Vec<NameView>,
pub sex: Option<String>,
pub events: Vec<EventView>,
pub child_in: Vec<FamilyLinkView>,
pub partner_in: Vec<FamilyLinkView>,
pub citations: Vec<String>,
pub media: Vec<String>,
pub identifiers: Vec<IdentifierView>,
}
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
#[non_exhaustive]
pub struct NameView {
pub display: String,
pub sort: String,
pub original: String,
pub kind: Option<String>,
pub citations: Vec<String>,
}
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
#[non_exhaustive]
pub struct FamilyLinkView {
pub family: String,
pub pedigree: Option<String>,
}
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
#[non_exhaustive]
pub struct IdentifierView {
pub tag: String,
pub label: String,
pub value: String,
pub kind: Option<String>,
}
impl IndividualView {
#[must_use]
pub fn from_node(record: &Node) -> Self {
let mut names = Vec::new();
let mut events = Vec::new();
let mut child_in = Vec::new();
let mut partner_in = Vec::new();
let mut citations = Vec::new();
let mut media = Vec::new();
let mut identifiers = Vec::new();
for (index, child) in record.children.iter().enumerate() {
match child.tag.as_str() {
"NAME" => {
let name = PersonalName::from_node(child);
names.push(NameView {
display: name.display(),
sort: name.sort_form(),
original: name.original.clone(),
kind: name.kind.clone(),
citations: pointers_of(child, "SOUR"),
});
}
"FAMC" => {
if let Some(family) = child.pointer() {
child_in.push(FamilyLinkView {
family: family.to_owned(),
pedigree: child
.value_of("PEDI")
.map(|value| tags::pedigree_label(&value).to_owned()),
});
}
}
"FAMS" => {
if let Some(family) = child.pointer() {
partner_in.push(FamilyLinkView {
family: family.to_owned(),
pedigree: None,
});
}
}
"SOUR" => {
if let Some(pointer) = child.pointer() {
citations.push(pointer.to_owned());
}
}
"OBJE" => {
if let Some(pointer) = child.pointer() {
media.push(pointer.to_owned());
}
}
"REFN" | "RIN" | "EXID" | "UID" | "_UID" => identifiers.push(IdentifierView {
tag: child.tag.clone(),
label: tags::display_label(&child.tag).to_owned(),
value: child.logical_value(),
kind: child.value_of("TYPE"),
}),
tag if tags::is_event(tag) || tags::is_attribute(tag) => {
events.push(EventView::from_node(child, vec![index]));
}
tag if tags::is_extension(tag) && tag.contains("ID") => {
identifiers.push(IdentifierView {
tag: child.tag.clone(),
label: tags::display_label(&child.tag).to_owned(),
value: child.logical_value(),
kind: None,
});
}
_ => {}
}
}
Self {
xref: record.xref.clone().unwrap_or_default(),
names,
sex: record
.value_of("SEX")
.map(|value| tags::sex_label(&value).to_owned()),
events,
child_in,
partner_in,
citations,
media,
identifiers,
}
}
#[must_use]
pub fn display_name(&self) -> String {
self.names
.first()
.map_or_else(|| "Unnamed".to_owned(), |name| name.display.clone())
}
#[must_use]
pub fn event_year(&self, tag: &str) -> Option<i32> {
self.events
.iter()
.find(|event| event.tag == tag)
.and_then(|event| event.year)
}
#[must_use]
pub fn life_years(&self) -> Option<String> {
let year = |tag: &str| self.event_year(tag);
match (year("BIRT").or_else(|| year("CHR")), year("DEAT")) {
(Some(birth), Some(death)) => Some(format!("{birth}–{death}")),
(Some(birth), None) => Some(format!("b. {birth}")),
(None, Some(death)) => Some(format!("d. {death}")),
(None, None) => None,
}
}
}
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
#[non_exhaustive]
pub struct FamilyView {
pub xref: String,
pub husband: Option<String>,
pub wife: Option<String>,
pub children: Vec<String>,
pub events: Vec<EventView>,
}
impl FamilyView {
#[must_use]
pub fn from_node(record: &Node) -> Self {
let mut children = Vec::new();
let mut events = Vec::new();
for (index, child) in record.children.iter().enumerate() {
match child.tag.as_str() {
"CHIL" => {
if let Some(pointer) = child.pointer() {
children.push(pointer.to_owned());
}
}
tag if tags::is_event(tag) => events.push(EventView::from_node(child, vec![index])),
_ => {}
}
}
Self {
xref: record.xref.clone().unwrap_or_default(),
husband: record
.first("HUSB")
.and_then(|node| node.pointer())
.map(str::to_owned),
wife: record
.first("WIFE")
.and_then(|node| node.pointer())
.map(str::to_owned),
children,
events,
}
}
#[must_use]
pub fn partners(&self) -> Vec<String> {
[self.husband.clone(), self.wife.clone()]
.into_iter()
.flatten()
.collect()
}
}
fn pointers_of(node: &Node, tag: &str) -> Vec<String> {
node.all(tag)
.filter_map(|child| child.pointer().map(str::to_owned))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Document;
const SAMPLE: &str = "0 HEAD\n\
0 @I1@ INDI\n\
1 NAME Mira Elowen /North Quill/\n\
2 SOUR @S1@\n\
1 SEX F\n\
1 BIRT\n\
2 DATE 14 February 1904\n\
2 PLAC Northbridge, Example County\n\
1 DEAT\n\
2 DATE 9 October 1987\n\
1 RESI\n\
2 PLAC Southmere, Example County\n\
1 FAMC @F7@\n\
2 PEDI adopted\n\
1 FAMS @F2@\n\
1 REFN X9Q2-TEST\n\
2 TYPE FamilySearch\n\
1 _FSFTID X9Q2-TEST\n\
1 NOTE Synthetic example.\n\
0 TRLR\n";
fn individual() -> Node {
Document::parse(SAMPLE)
.expect("parse")
.record("@I1@")
.expect("individual")
.clone()
}
#[test]
fn every_structure_reaches_the_view_including_the_ones_we_do_not_understand() {
let record = individual();
let view = RecordView::from_node(&record);
let tags: Vec<&str> = view
.structures
.iter()
.map(|structure| structure.tag.as_str())
.collect();
assert_eq!(
tags,
[
"NAME", "SEX", "BIRT", "DEAT", "RESI", "FAMC", "FAMS", "REFN", "_FSFTID", "NOTE"
]
);
let extension = view
.structures
.iter()
.find(|structure| structure.tag == "_FSFTID")
.expect("the extension is shown");
assert_eq!(extension.kind, "unknown");
assert!(extension.extension);
assert_eq!(extension.value, "X9Q2-TEST");
assert_eq!(extension.label, "_FSFTID");
}
#[test]
fn the_raw_panel_is_the_record_exactly_as_it_will_be_written() {
let record = individual();
let view = RecordView::from_node(&record);
assert!(
view.raw
.starts_with("0 @I1@ INDI\n1 NAME Mira Elowen /North Quill/\n")
);
assert!(view.raw.contains("1 _FSFTID X9Q2-TEST\n"));
}
#[test]
fn an_individual_reads_into_names_events_families_and_identifiers() {
let view = IndividualView::from_node(&individual());
assert_eq!(view.display_name(), "Mira Elowen North Quill");
assert_eq!(view.sex.as_deref(), Some("Female"));
assert_eq!(view.life_years().as_deref(), Some("1904–1987"));
assert_eq!(view.names[0].citations, ["@S1@"]);
assert_eq!(view.child_in[0].pedigree.as_deref(), Some("Adopted"));
assert_eq!(view.partner_in[0].family, "@F2@");
let events: Vec<&str> = view.events.iter().map(|event| event.tag.as_str()).collect();
assert_eq!(events, ["BIRT", "DEAT", "RESI"]);
assert!(view.events[2].attribute, "RESI is an attribute");
assert_eq!(
view.events[0].date_display.as_deref(),
Some("14 February 1904")
);
let identifiers: Vec<&str> = view
.identifiers
.iter()
.map(|identifier| identifier.tag.as_str())
.collect();
assert_eq!(identifiers, ["REFN", "_FSFTID"]);
assert_eq!(view.identifiers[0].kind.as_deref(), Some("FamilySearch"));
}
#[test]
fn a_record_type_we_do_not_know_still_has_a_title_and_a_view() {
let record = Document::parse("0 HEAD\n0 @X1@ _OPAQUE\n1 QUERY Tova Testwright\n0 TRLR\n")
.expect("parse")
.record("@X1@")
.expect("record")
.clone();
let view = RecordView::from_node(&record);
assert_eq!(view.tag, "_OPAQUE");
assert_eq!(view.label, "_OPAQUE");
assert_eq!(view.structures[0].tag, "QUERY");
assert_eq!(view.structures[0].value, "Tova Testwright");
}
#[test]
fn a_family_reads_its_partners_children_and_events() {
let record = Document::parse(
"0 HEAD\n0 @F1@ FAM\n1 HUSB @I1@\n1 WIFE @I2@\n1 CHIL @I3@\n1 CHIL @I4@\n1 MARR\n2 DATE 1880\n0 TRLR\n",
)
.expect("parse")
.record("@F1@")
.expect("family")
.clone();
let view = FamilyView::from_node(&record);
assert_eq!(view.partners(), ["@I1@", "@I2@"]);
assert_eq!(view.children, ["@I3@", "@I4@"]);
assert_eq!(view.events[0].tag, "MARR");
assert_eq!(view.events[0].date.as_deref(), Some("1880"));
}
#[test]
fn a_generic_event_is_labelled_by_what_the_file_said_it_was() {
let record = Document::parse(
"0 HEAD\n0 @I1@ INDI\n1 EVEN\n2 TYPE Arrival in Minnesota\n2 DATE 1899\n0 TRLR\n",
)
.expect("parse")
.record("@I1@")
.expect("individual")
.clone();
let view = IndividualView::from_node(&record);
assert_eq!(view.events[0].label, "Arrival in Minnesota");
}
}