use crate::dates::GedcomDate;
use crate::{Document, Node, schema, tags};
use std::fmt::Write as _;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum ConversionKind {
Converted,
Dropped,
Kept,
}
impl ConversionKind {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Converted => "converted",
Self::Dropped => "dropped",
Self::Kept => "kept as written",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub struct ConversionNote {
pub record: Option<String>,
pub tag: String,
pub kind: ConversionKind,
pub detail: String,
}
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct Conversion {
pub document: Document,
pub from: String,
pub to: String,
pub notes: Vec<ConversionNote>,
}
impl Conversion {
#[must_use]
pub fn counts(&self) -> (usize, usize, usize) {
let mut converted = 0;
let mut dropped = 0;
let mut kept = 0;
for note in &self.notes {
match note.kind {
ConversionKind::Converted => converted += 1,
ConversionKind::Dropped => dropped += 1,
ConversionKind::Kept => kept += 1,
}
}
(converted, dropped, kept)
}
}
const UNDOCUMENTED: &str = "https://gedcom.example/undocumented";
struct Pass {
notes: Vec<ConversionNote>,
record: Option<String>,
}
impl Pass {
fn note(&mut self, tag: &str, kind: ConversionKind, detail: impl Into<String>) {
self.notes.push(ConversionNote {
record: self.record.clone(),
tag: tag.to_owned(),
kind,
detail: detail.into(),
});
}
}
#[must_use]
pub fn to_version_7(document: &Document) -> Conversion {
to_version_7_with(document, &[])
}
#[must_use]
pub fn to_version_7_with(document: &Document, owned: &[(&str, &str)]) -> Conversion {
let from = document.version().unwrap_or_else(|| "unstated".to_owned());
let mut converted = document.clone();
let mut pass = Pass {
notes: Vec::new(),
record: None,
};
let shared: Vec<String> = converted
.records_of("NOTE")
.filter_map(|record| record.xref.clone())
.collect();
for record in &mut converted.records {
pass.record = record.xref.clone();
if record.tag == "HEAD" {
head_to_7(record, &mut pass);
}
if record.tag == "NOTE" && record.xref.is_some() {
"SNOTE".clone_into(&mut record.tag);
record.forget_verbatim();
pass.note(
"NOTE",
ConversionKind::Converted,
"a note record is a shared note record in version 7",
);
}
structures_to_7_node(record, &shared, &mut pass);
}
let extension_tags = extension_tags(&converted);
if let Some(header) = converted.records.iter_mut().find(|node| node.tag == "HEAD") {
pass.record = None;
for tag in extension_tags {
if schema::is_declared(header, &tag) {
continue;
}
if let Some((_, uri)) = owned.iter().find(|(own, _)| *own == tag) {
schema::declare(header, &tag, uri);
header.forget_verbatim();
pass.note(
&tag,
ConversionKind::Converted,
"declared in the header, which version 7 requires of every extension",
);
} else {
schema::declare(header, &tag, UNDOCUMENTED);
header.forget_verbatim();
pass.note(
&tag,
ConversionKind::Kept,
"another application's extension, declared as undocumented rather than dropped",
);
}
}
}
Conversion {
document: converted,
from,
to: "7.0".to_owned(),
notes: pass.notes,
}
}
fn head_to_7(header: &mut Node, pass: &mut Pass) {
header.forget_verbatim();
if let Some(gedc) = header.first_mut("GEDC") {
gedc.forget_verbatim();
if let Some(vers) = gedc.first_mut("VERS") {
vers.set_value(Some("7.0".to_owned()));
} else {
gedc.push(Node::with_value("VERS", "7.0"));
}
if remove_child(gedc, "FORM") {
pass.note(
"GEDC.FORM",
ConversionKind::Dropped,
"version 7 has one form and does not name it",
);
}
} else {
header
.children
.insert(0, Node::new("GEDC").child(Node::with_value("VERS", "7.0")));
pass.note(
"GEDC",
ConversionKind::Converted,
"the header declared no GEDCOM version, and version 7 requires it",
);
}
if remove_child(header, "CHAR") {
pass.note(
"CHAR",
ConversionKind::Dropped,
"version 7 is always UTF-8, so a character set declaration says nothing",
);
}
}
fn structures_to_7_node(node: &mut Node, shared: &[String], pass: &mut Pass) -> bool {
let mut changed = false;
if node.tag == "_UID" {
"UID".clone_into(&mut node.tag);
changed = true;
pass.note(
"_UID",
ConversionKind::Converted,
"version 7 defines UID, so the extension is no longer needed",
);
}
if node.tag == "NOTE"
&& let Some(pointer) = node.pointer()
&& shared.iter().any(|xref| xref.eq_ignore_ascii_case(pointer))
{
"SNOTE".clone_into(&mut node.tag);
changed = true;
pass.note(
"NOTE",
ConversionKind::Converted,
"a pointer to a note record is a shared note pointer in version 7",
);
}
if node.tag == "DATE" {
changed |= date_to_7(node, pass);
}
if node.tag == "OBJE" && node.first("FILE").is_some() {
changed |= media_form_to_7(node, pass);
}
for child in &mut node.children {
changed |= structures_to_7_node(child, shared, pass);
}
if changed {
node.forget_verbatim();
}
changed
}
fn date_to_7(node: &mut Node, pass: &mut Pass) -> bool {
let payload = node.logical_value();
if payload.trim().is_empty() {
return false;
}
let date = GedcomDate::parse(&payload);
let kept = date.phrase.clone().unwrap_or_else(|| payload.clone());
let reading = date.to_gedcom_7();
if reading.as_deref() == Some(payload.as_str()) {
return false;
}
if node.first("PHRASE").is_some() {
pass.note(
"DATE",
ConversionKind::Kept,
format!(
"{payload:?} is not version 7's date grammar, but the DATE already has a PHRASE, so the original cannot be kept beside a rewrite; it is left as it is"
),
);
return false;
}
node.set_logical_value(reading.as_deref().unwrap_or(""));
node.push(Node::with_value("PHRASE", kept));
let detail = if reading.is_some() {
format!(
"{payload:?} is not version 7's date grammar; the reading it converted to is kept as the date, and the original as a PHRASE"
)
} else {
format!(
"version 7's date grammar cannot hold {payload:?}; the DATE is left empty and the original kept as a PHRASE"
)
};
pass.note("DATE", ConversionKind::Converted, detail);
true
}
fn media_form_to_7(object: &mut Node, pass: &mut Pass) -> bool {
let moved = object.first("FORM").cloned();
let mut changed = moved.is_some();
if let Some(form) = moved {
remove_child(object, "FORM");
for file in object.children.iter_mut().filter(|node| node.tag == "FILE") {
if file.first("FORM").is_none() {
file.push(form.clone());
file.forget_verbatim();
}
}
pass.note(
"OBJE.FORM",
ConversionKind::Converted,
"version 7 records a file's format on the file, not on the object",
);
}
for file in object.children.iter_mut().filter(|node| node.tag == "FILE") {
changed |= file_to_7(file, pass);
}
changed
}
fn file_to_7(file: &mut Node, pass: &mut Pass) -> bool {
let mut changed = false;
let path = file.logical_value();
let encoded = uri_reference(&path);
if encoded != path {
file.set_logical_value(&encoded);
pass.note(
"FILE",
ConversionKind::Converted,
format!(
"{path:?} is not a URI reference, which version 7 requires of a file path; characters a URI cannot hold were percent-encoded"
),
);
changed = true;
}
if let Some(form) = file.first_mut("FORM") {
let form_changed = form_to_7(form, pass);
if form_changed {
file.forget_verbatim();
}
changed |= form_changed;
}
changed
}
fn form_to_7(form: &mut Node, pass: &mut Pass) -> bool {
let mut changed = false;
let value = form.logical_value();
let value = value.trim();
if !value.is_empty() && !value.contains('/') {
match media_type_for_extension(value) {
Some(media_type) => {
form.set_logical_value(media_type);
pass.note(
"FILE.FORM",
ConversionKind::Converted,
format!("the file extension {value:?} became the media type {media_type:?}"),
);
changed = true;
}
None => pass.note(
"FILE.FORM",
ConversionKind::Kept,
format!(
"{value:?} has no known version 7 media type, so it is kept rather than guessed"
),
),
}
}
for medium in form
.children
.iter_mut()
.filter(|node| matches!(node.tag.as_str(), "TYPE" | "MEDI"))
{
changed |= medium_node_to_7(medium, pass);
}
changed
}
fn medium_node_to_7(medium: &mut Node, pass: &mut Pass) -> bool {
let changed = medium.tag == "TYPE";
if changed {
medium.set_tag("MEDI");
pass.note(
"FORM.MEDI",
ConversionKind::Converted,
"5.5.1's source medium under FORM is TYPE; version 7 calls it MEDI",
);
}
let original = medium.logical_value();
let (value, known) = medium_to_7(&original);
if value == original {
return changed;
}
if !known && medium.first("PHRASE").is_some() {
pass.note(
"FORM.MEDI",
ConversionKind::Kept,
format!(
"the source medium {original:?} matches none of version 7's enumerated values, but it already has a PHRASE to keep the original in; it is left as it is"
),
);
return changed;
}
medium.set_logical_value(&value);
if known {
pass.note(
"FORM.MEDI",
ConversionKind::Converted,
format!("the source medium {original:?} became the enumerated value {value}"),
);
} else {
medium.push(Node::with_value("PHRASE", original.trim()));
pass.note(
"FORM.MEDI",
ConversionKind::Converted,
format!(
"the source medium {original:?} matches none of version 7's enumerated values; it became OTHER, with the original kept as a PHRASE"
),
);
}
true
}
fn uri_reference(path: &str) -> String {
const ALLOWED: &[u8] = b"-._~:/?#[]@!$&'()*+,;=";
let bytes = path.as_bytes();
let mut out = String::with_capacity(path.len());
for (index, &byte) in bytes.iter().enumerate() {
let escape_begins = byte == b'%'
&& bytes.get(index + 1).is_some_and(u8::is_ascii_hexdigit)
&& bytes.get(index + 2).is_some_and(u8::is_ascii_hexdigit);
if byte.is_ascii_alphanumeric() || ALLOWED.contains(&byte) || escape_begins {
out.push(char::from(byte));
} else {
let _ = write!(out, "%{byte:02X}");
}
}
out
}
fn media_type_for_extension(extension: &str) -> Option<&'static str> {
match extension.trim().to_ascii_lowercase().as_str() {
"jpg" | "jpeg" => Some("image/jpeg"),
"png" => Some("image/png"),
"gif" => Some("image/gif"),
"bmp" => Some("image/bmp"),
"tif" | "tiff" => Some("image/tiff"),
"pdf" => Some("application/pdf"),
"txt" => Some("text/plain"),
"html" | "htm" => Some("text/html"),
"wav" => Some("audio/x-wav"),
"mp3" => Some("audio/mpeg"),
"heic" => Some("image/heic"),
"webp" => Some("image/webp"),
"svg" => Some("image/svg+xml"),
"json" => Some("application/json"),
"xml" => Some("application/xml"),
"eml" => Some("message/rfc822"),
"mp4" => Some("video/mp4"),
_ => None,
}
}
fn medium_to_7(medium: &str) -> (String, bool) {
let upper = medium.trim().to_ascii_uppercase();
let known = matches!(
upper.as_str(),
"AUDIO"
| "BOOK"
| "CARD"
| "ELECTRONIC"
| "FICHE"
| "FILM"
| "MAGAZINE"
| "MANUSCRIPT"
| "MAP"
| "NEWSPAPER"
| "PHOTO"
| "TOMBSTONE"
| "VIDEO"
);
if known {
(upper, true)
} else {
("OTHER".to_owned(), false)
}
}
#[must_use]
pub fn to_version_5_5_1(document: &Document) -> Conversion {
let from = document.version().unwrap_or_else(|| "unstated".to_owned());
let mut converted = document.clone();
let mut pass = Pass {
notes: Vec::new(),
record: None,
};
for record in &mut converted.records {
pass.record = record.xref.clone();
if record.tag == "HEAD" {
head_to_5(record, &mut pass);
}
if record.tag == "SNOTE" {
"NOTE".clone_into(&mut record.tag);
record.forget_verbatim();
pass.note(
"SNOTE",
ConversionKind::Converted,
"5.5.1 has one kind of note record",
);
}
structures_to_5_node(record, &mut pass);
}
Conversion {
document: converted,
from,
to: "5.5.1".to_owned(),
notes: pass.notes,
}
}
fn head_to_5(header: &mut Node, pass: &mut Pass) {
header.forget_verbatim();
if let Some(gedc) = header.first_mut("GEDC") {
gedc.forget_verbatim();
if let Some(vers) = gedc.first_mut("VERS") {
vers.set_value(Some("5.5.1".to_owned()));
} else {
gedc.push(Node::with_value("VERS", "5.5.1"));
}
if gedc.first("FORM").is_none() {
gedc.push(Node::with_value("FORM", "LINEAGE-LINKED"));
}
} else {
header.children.insert(
0,
Node::new("GEDC")
.child(Node::with_value("VERS", "5.5.1"))
.child(Node::with_value("FORM", "LINEAGE-LINKED")),
);
pass.note(
"GEDC",
ConversionKind::Converted,
"the header declared no GEDCOM version, and 5.5.1 requires it",
);
}
if header.first("CHAR").is_none() {
header.push(Node::with_value("CHAR", "UTF-8"));
pass.note(
"CHAR",
ConversionKind::Converted,
"5.5.1 requires a character set, and the file is written as UTF-8",
);
}
if remove_child(header, "SCHMA") {
pass.note(
"SCHMA",
ConversionKind::Dropped,
"5.5.1 has no way to declare an extension; the extensions themselves are kept",
);
}
}
fn structures_to_5_node(node: &mut Node, pass: &mut Pass) -> bool {
let mut changed = false;
if node.tag == "UID" {
"_UID".clone_into(&mut node.tag);
changed = true;
pass.note(
"UID",
ConversionKind::Converted,
"5.5.1 has no UID, and every producer writes _UID instead",
);
}
if node.tag == "SNOTE" && node.pointer().is_some() {
"NOTE".clone_into(&mut node.tag);
changed = true;
pass.note(
"SNOTE",
ConversionKind::Converted,
"a shared note pointer is an ordinary note pointer in 5.5.1",
);
}
if node.tag == "DATE"
&& let Some(phrase) = node.value_of("PHRASE")
{
let payload = node.logical_value();
remove_child(node, "PHRASE");
node.set_value(Some(format!("INT {payload} ({phrase})")));
changed = true;
pass.note(
"PHRASE",
ConversionKind::Converted,
"a dated phrase became 5.5.1's interpreted form",
);
}
if matches!(node.tag.as_str(), "TRAN" | "CREA" | "EXID" | "SDATE") {
pass.note(
&node.tag,
ConversionKind::Kept,
"version 7 defines this and 5.5.1 does not; it is kept rather than lost",
);
}
for child in &mut node.children {
changed |= structures_to_5_node(child, pass);
}
if changed {
node.forget_verbatim();
}
changed
}
fn remove_child(node: &mut Node, tag: &str) -> bool {
let before = node.children.len();
node.children.retain(|child| child.tag != tag);
if node.children.len() != before {
node.forget_verbatim();
return true;
}
false
}
fn extension_tags(document: &Document) -> Vec<String> {
let mut tags: Vec<String> = Vec::new();
for record in &document.records {
if record.tag == "HEAD" {
continue;
}
for node in record.walk() {
if tags::is_extension(&node.tag) && !tags.contains(&node.tag) {
tags.push(node.tag.clone());
}
}
}
tags.sort();
tags
}
#[cfg(test)]
mod tests {
use super::*;
const OLD: &str = "0 HEAD\n\
1 GEDC\n\
2 VERS 5.5.1\n\
2 FORM LINEAGE-LINKED\n\
1 CHAR UTF-8\n\
0 @I1@ INDI\n\
1 NAME Rian /Testwright/\n\
1 _UID 4E7C9F2A\n\
1 BIRT\n\
2 DATE INT 1 JAN 1900 (from the family bible)\n\
1 NOTE @N1@\n\
1 _SOMEBODY _ELSE\n\
0 @N1@ NOTE\n\
1 CONC Written by hand.\n\
0 @M1@ OBJE\n\
1 FILE media/photo.jpg\n\
1 FORM jpeg\n\
2 MEDI photo\n\
0 TRLR\n";
fn parse(text: &str) -> Document {
Document::parse(text).expect("parse")
}
#[test]
fn five_five_one_becomes_version_seven() {
let outcome = to_version_7(&parse(OLD));
let text = outcome.document.to_text();
assert_eq!(outcome.from, "5.5.1");
assert_eq!(outcome.to, "7.0");
assert!(text.contains("2 VERS 7.0"), "{text}");
assert!(!text.contains("CHAR"), "the character set went: {text}");
assert!(!text.contains("2 FORM LINEAGE-LINKED"), "{text}");
assert!(text.contains("0 @N1@ SNOTE"), "the record: {text}");
assert!(
text.contains("1 SNOTE @N1@"),
"and the pointer at it: {text}"
);
assert!(text.contains("1 UID 4E7C9F2A"), "{text}");
assert!(text.contains("2 DATE 1 JAN 1900"), "{text}");
assert!(
text.contains("3 PHRASE from the family bible"),
"the reading is kept as a phrase: {text}"
);
assert!(text.contains("2 FORM image/jpeg"), "{text}");
assert!(text.contains("3 MEDI PHOTO"), "{text}");
assert!(text.contains("2 TAG _SOMEBODY "), "{text}");
assert!(text.contains("undocumented"), "{text}");
assert!(text.contains("1 _SOMEBODY _ELSE"), "and kept: {text}");
}
#[test]
fn version_seven_becomes_five_five_one() {
let seven = to_version_7(&parse(OLD)).document;
let outcome = to_version_5_5_1(&seven);
let text = outcome.document.to_text();
assert!(text.contains("2 VERS 5.5.1"), "{text}");
assert!(text.contains("2 FORM LINEAGE-LINKED"), "{text}");
assert!(text.contains("1 CHAR UTF-8"), "{text}");
assert!(!text.contains("SCHMA"), "the declarations went: {text}");
assert!(text.contains("0 @N1@ NOTE"), "{text}");
assert!(text.contains("1 NOTE @N1@"), "{text}");
assert!(text.contains("1 _UID 4E7C9F2A"), "{text}");
assert!(
text.contains("2 DATE INT 1 JAN 1900 (from the family bible)"),
"the phrase folds back into the interpreted form: {text}"
);
assert!(text.contains("1 _SOMEBODY _ELSE"), "{text}");
}
#[test]
fn the_report_names_every_construct_it_touched() {
let outcome = to_version_7(&parse(OLD));
let names: Vec<&str> = outcome.notes.iter().map(|note| note.tag.as_str()).collect();
for expected in [
"CHAR",
"GEDC.FORM",
"_UID",
"NOTE",
"DATE",
"OBJE.FORM",
"FILE.FORM",
"FORM.MEDI",
] {
assert!(
names.contains(&expected),
"{expected} missing from {names:?}"
);
}
let (converted, dropped, kept) = outcome.counts();
assert!(converted > 0 && dropped > 0, "{:?}", outcome.notes);
assert_eq!(
kept, 1,
"only the undeclared extension: {:?}",
outcome.notes
);
let uid = outcome
.notes
.iter()
.find(|note| note.tag == "_UID")
.expect("_UID note");
assert_eq!(uid.record.as_deref(), Some("@I1@"));
}
#[test]
fn an_unparsed_date_becomes_an_empty_date_with_a_phrase() {
let document =
parse("0 HEAD\n1 GEDC\n2 VERS 5.5.1\n0 @I1@ INDI\n1 BIRT\n2 DATE Infant\n0 TRLR\n");
let outcome = to_version_7(&document);
let text = outcome.document.to_text();
assert!(text.contains("2 DATE\n3 PHRASE Infant\n"), "{text}");
let note = outcome
.notes
.iter()
.find(|note| note.tag == "DATE")
.expect("a note about it");
assert_eq!(note.kind, ConversionKind::Converted);
assert!(note.detail.contains("Infant"), "{}", note.detail);
}
#[test]
fn dates_already_in_version_7_grammar_are_left_untouched() {
let document = parse(
"0 HEAD\n1 GEDC\n2 VERS 7.0\n0 @I1@ INDI\n1 BIRT\n2 DATE 29 MAY 1823\n\
1 DEAT\n2 DATE ABT 1900\n3 PHRASE around the turn of the century\n0 TRLR\n",
);
let outcome = to_version_7(&document);
assert_eq!(
outcome.document.to_text(),
document.to_text(),
"an already-conformant file is untouched byte for byte"
);
assert!(
outcome.notes.iter().all(|note| note.tag != "DATE"),
"nothing to report about a date already in version 7's grammar: {:?}",
outcome.notes
);
}
#[test]
fn dates_the_lenient_reader_understands_but_5_5_1_grammar_that_is_not_7_0_grammar() {
let cases: &[(&str, &str)] = &[
("29 May 1823", "29 MAY 1823"),
("about 1575", "ABT 1575"),
("13. september 1900", "13 SEP 1900"),
("<1873>", "ABT 1873"),
("ABT Oct 1803", "ABT OCT 1803"),
("44 B.C.", "44 BCE"),
("@#DJULIAN@ 10 MAR 1700", "JULIAN 10 MAR 1700"),
];
for (original, rendered) in cases {
let document = parse(&format!(
"0 HEAD\n1 GEDC\n2 VERS 5.5.1\n0 @I1@ INDI\n1 BIRT\n2 DATE {original}\n0 TRLR\n"
));
let outcome = to_version_7(&document);
let text = outcome.document.to_text();
assert!(
text.contains(&format!("2 DATE {rendered}\n3 PHRASE {original}\n")),
"{original:?} -> {text}"
);
let note = outcome
.notes
.iter()
.find(|note| note.tag == "DATE")
.unwrap_or_else(|| panic!("a note about {original:?}"));
assert_eq!(note.kind, ConversionKind::Converted, "{original:?}");
}
}
#[test]
fn a_dual_year_has_no_version_7_reading_and_becomes_an_empty_date_with_a_phrase() {
let document =
parse("0 HEAD\n1 GEDC\n2 VERS 5.5.1\n0 @I1@ INDI\n1 BIRT\n2 DATE 1740/41\n0 TRLR\n");
let outcome = to_version_7(&document);
let text = outcome.document.to_text();
assert!(text.contains("2 DATE\n3 PHRASE 1740/41\n"), "{text}");
}
#[test]
fn an_unrecognized_extension_is_kept_rather_than_guessed() {
let document = parse(
"0 HEAD\n1 GEDC\n2 VERS 5.5.1\n0 @M1@ OBJE\n1 FILE media/document.ole\n\
1 FORM ole\n0 TRLR\n",
);
let outcome = to_version_7(&document);
let text = outcome.document.to_text();
assert!(text.contains("2 FORM ole"), "kept as written: {text}");
let note = outcome
.notes
.iter()
.find(|note| note.tag == "FILE.FORM")
.expect("a note about the extension");
assert_eq!(note.kind, ConversionKind::Kept);
assert!(note.detail.contains("ole"), "{}", note.detail);
}
#[test]
fn an_unrecognized_source_medium_becomes_other_with_a_phrase() {
let document = parse(
"0 HEAD\n1 GEDC\n2 VERS 5.5.1\n0 @M1@ OBJE\n1 FILE media/clipping.jpg\n\
1 FORM jpg\n2 MEDI newspaper clipping\n0 TRLR\n",
);
let outcome = to_version_7(&document);
let text = outcome.document.to_text();
assert!(
text.contains("3 MEDI OTHER\n4 PHRASE newspaper clipping\n"),
"{text}"
);
let note = outcome
.notes
.iter()
.find(|note| note.tag == "FORM.MEDI")
.expect("a note about the medium");
assert_eq!(note.kind, ConversionKind::Converted);
assert!(
note.detail.contains("newspaper clipping"),
"{}",
note.detail
);
}
#[test]
fn the_5_5_1_media_layout_under_file_is_converted() {
let document = parse(
"0 HEAD\n1 GEDC\n2 VERS 5.5.1\n0 @M1@ OBJE\n1 FILE media/my photo.JPG\n\
2 FORM JPG\n3 TYPE manuscript\n2 TITL A page\n0 TRLR\n",
);
let outcome = to_version_7(&document);
let text = outcome.document.to_text();
assert!(
text.contains(
"1 FILE media/my%20photo.JPG\n2 FORM image/jpeg\n3 MEDI MANUSCRIPT\n2 TITL A page\n"
),
"{text}"
);
for tag in ["FILE", "FILE.FORM", "FORM.MEDI"] {
assert!(
outcome.notes.iter().any(|note| note.tag == tag),
"{tag} is reported: {:?}",
outcome.notes
);
}
}
#[test]
fn a_path_that_is_already_a_uri_reference_is_untouched() {
assert_eq!(uri_reference("media/a%20b.jpg"), "media/a%20b.jpg");
assert_eq!(uri_reference("../x/y_z-1.png"), "../x/y_z-1.png");
assert_eq!(uri_reference("50% off.jpg"), "50%25%20off.jpg");
assert_eq!(uri_reference("C:\\a\\é.jpg"), "C:%5Ca%5C%C3%A9.jpg");
}
#[test]
fn a_date_that_already_has_a_phrase_is_kept_and_reported() {
let document = parse(
"0 HEAD\n1 GEDC\n2 VERS 5.5.1\n0 @I1@ INDI\n1 BIRT\n2 DATE 29 May 1823\n\
3 PHRASE late May, the family said\n0 TRLR\n",
);
let outcome = to_version_7(&document);
let text = outcome.document.to_text();
assert!(
text.contains("2 DATE 29 May 1823\n3 PHRASE late May, the family said\n"),
"{text}"
);
assert_eq!(text.matches("PHRASE").count(), 1);
assert!(
outcome
.notes
.iter()
.any(|note| note.tag == "DATE" && note.kind == ConversionKind::Kept)
);
}
#[test]
fn a_rewritten_date_loses_its_old_continuation_lines() {
let document = parse(
"0 HEAD\n1 GEDC\n2 VERS 5.5.1\n0 @I1@ INDI\n1 BIRT\n2 DATE 29 May\n3 CONC 1823\n0 TRLR\n",
);
let outcome = to_version_7(&document);
let text = outcome.document.to_text();
assert!(!text.contains("CONC"), "{text}");
let reread = Document::parse(&text).expect("reads back");
let date = reread
.record("@I1@")
.and_then(|person| person.first("BIRT"))
.and_then(|birth| birth.first("DATE"))
.expect("date");
assert_eq!(date.logical_value(), "29 MAY 1823");
assert_eq!(date.value_of("PHRASE").as_deref(), Some("29 May 1823"));
}
#[test]
fn media_already_in_version_7_shape_is_left_untouched() {
let document = parse(
"0 HEAD\n1 GEDC\n2 VERS 7.0\n0 @M1@ OBJE\n1 FILE media/photo.jpg\n\
2 FORM image/jpeg\n3 MEDI PHOTO\n0 TRLR\n",
);
let outcome = to_version_7(&document);
assert_eq!(
outcome.document.to_text(),
document.to_text(),
"a file whose media is already version 7's shape is untouched byte for byte"
);
assert!(
outcome.notes.iter().all(|note| note.tag != "OBJE.FORM"
&& note.tag != "FILE.FORM"
&& note.tag != "FORM.MEDI"),
"nothing to report: {:?}",
outcome.notes
);
}
#[test]
fn an_extension_the_caller_owns_is_declared_against_its_own_uri() {
let document = parse(
"0 HEAD\n1 GEDC\n2 VERS 5.5.1\n0 @I1@ INDI\n1 _MINE value\n1 _THEIRS value\n0 TRLR\n",
);
let outcome = to_version_7_with(&document, &[("_MINE", "https://mine.example/MINE")]);
let text = outcome.document.to_text();
assert!(
text.contains("2 TAG _MINE https://mine.example/MINE"),
"{text}"
);
assert!(
text.contains("2 TAG _THEIRS https://gedcom.example/undocumented"),
"{text}"
);
let mine = outcome
.notes
.iter()
.find(|note| note.tag == "_MINE")
.expect("a note about the owned tag");
assert_eq!(mine.kind, ConversionKind::Converted);
}
#[test]
fn converting_to_the_version_a_file_already_declares_changes_nothing_it_holds() {
let seven = to_version_7(&parse(OLD)).document;
let again = to_version_7(&seven);
assert_eq!(
again.document.to_text(),
seven.to_text(),
"a second conversion is a no-op"
);
}
}