GEDCOM genealogy files
GEDCOM 5.5.1 and 7 genealogy files: individuals and families with their dates, approximate, ranged or in other calendars, trees walked up and down, how two people are related, broken links found, and the records written back.
Version 1.0.0 · 10 functions · 1 crate
Functions
What use gedcom.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use gedcom_date, gedcom_parse from gedcom (Quillo 0.6).
gedcom_date(text: str) → anyReads a GEDCOM date: { text, kind, calendar, year, month, day, to, phrase, sort }. kind is exact, about, calculated, estimated, before, after, between, from, to, period, interpreted or phrase; to holds the second date of a range; sort is a whole number for ordering, year then month then day. Takes ABT 1850, BET 1822 AND 1825, FROM 1880 TO 1881, @#DJULIAN@ 2 FEB 1852, and 7's JULIAN 1 JAN 1992 and BCE.
gedcom_parse(text: str) → anyor an errorA file's text as a whole: { version, encoding, source, individuals, families, counts, problems }, CONC and CONT folded, at signs undone.
gedcom_read_file(path: str) → anyor an errorReads a file from disk as gedcom_parse does, working out its encoding.
gedcom_check(text: str) → anyor an errorWhat is wrong with a file: { ok, problems }, pointers to nothing and links that go one way, by line.
gedcom_person(text: str, who: str) → anyor an errorOne person's family: { person, parents, spouses, children, siblings }.
gedcom_ancestors(text: str, who: str, generations: int) → listor an errorEveryone above someone, up to generations (0 for all): [{ id, name, generation }], parents being generation 1.
gedcom_descendants(text: str, who: str, generations: int) → listor an errorEveryone below someone, likewise.
gedcom_relationship(text: str, a: str, b: str) → anyor an errorHow a is related to b through the nearest common ancestor: { relation, common_ancestor, up, down }, relation being what a is to b, as "great-grandparent", "first cousin 1 time removed"; up and down the steps from b to the ancestor and from there to a. Null when they share no ancestor in the file.
gedcom_records(text: str) → listor an errorThe records as a plain tree of { xref, tag, value, children }, each with CONC and CONT folded: what gedcom_write takes back, for a program that edits the file.
gedcom_write(records: list) → stror an errorWrites records back as GEDCOM text: records as gedcom_records gives them. @ is doubled as the version in HEAD says, long lines of 5.5.1 go on in CONC, line breaks in CONT.
Get it
$ quillo install gedcom # gedcom.qwer into this folder$ quillo install gedcom --example # and its example, gedcom.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/gedcom.qwer$ curl -O https://quillolang.org/plugins/files/gedcom.qwe$ curl -O https://quillolang.org/plugins/files/gedcom_sample.ged$ curl -O https://quillolang.org/plugins/files/gedcom_v7_sample.gedThen, in your program, all of it or only what you call:
use gedcom.qweruse gedcom_date, gedcom_parse from gedcom // Quillo 0.6: only theseIt uses this crate, which cargo fetches the first time the plugin is built:
serde_json = { version = "1", features = ["preserve_order"] }Example
// A family tree from a GEDCOM file: who is in it, how they are related, what is broken, and the// file written back. gedcom_sample.ged is 5.5.1, gedcom_v7_sample.ged is 7; both made up.//// quillo run gedcom.qwe then open http://localhost:8080/family, /person/I3, /tree, /related, /dates, /v7, /roundtrip and /brokenuse "gedcom.qwer"let TEXT = read_file("gedcom_sample.ged")route GET /family { let g = gedcom_read_file("gedcom_sample.ged")? { version: g.version, encoding: g.encoding, counts: g.counts, note: g.note, problems: g.problems, people: g.individuals | map((p) => `${p.id} ${p.name} ${p.sex}`), married: g.families[0].marriage.date.text, }}route GET /person/:id = gedcom_person(TEXT, id)?route GET /tree = { up: gedcom_ancestors(TEXT, "I6", 0)?, one_up: gedcom_ancestors(TEXT, "@I6@", 1)? | map((p) => p.name), down: gedcom_descendants(TEXT, "I1", 0)? | map((p) => `${p.generation} ${p.name}`),}route GET /related = { parent: gedcom_relationship(TEXT, "I1", "I3")?.relation, child: gedcom_relationship(TEXT, "I3", "I1")?.relation, grand: gedcom_relationship(TEXT, "I1", "I6")?.relation, sibling: gedcom_relationship(TEXT, "I3", "I4")?, cousin: gedcom_relationship(TEXT, "I6", "I7")?, uncle: gedcom_relationship(TEXT, "I3", "I7")?.relation, none: gedcom_relationship(TEXT, "I5", "I2")?,}route GET /dates = { exact: gedcom_date("3 MAR 1888"), about: gedcom_date("ABT 1820"), between: gedcom_date("BET 1822 AND 1825"), period: gedcom_date("FROM 1880 TO 1881"), julian: gedcom_date("@#DJULIAN@ 2 FEB 1852"), dual: gedcom_date("11 FEB 1731/32").year, bc: gedcom_date("44 B.C.").year, hebrew: gedcom_date("@#DHEBREW@ 2 TVT 5758"), words: gedcom_date("(about the time of the flood)"), interpreted: gedcom_date("INT 1900 (from the census)"),}route GET /v7 { let g = gedcom_read_file("gedcom_v7_sample.ged")? let back = gedcom_write(gedcom_records(read_file("gedcom_v7_sample.ged"))?)? { version: g.version, note: g.note, birth: g.individuals[0].birth.date, death: g.individuals[0].death.date.calendar, far_back: g.individuals[1].birth.date.year, to: g.individuals[1].birth.date.to.year, problems: g.problems, written: back == read_file("gedcom_v7_sample.ged"), }}// Read and written back, the file comes out the same; edited, the edit is there.route GET /roundtrip { let records = gedcom_records(TEXT)? let out = gedcom_write(records)? let again = gedcom_records(out)? let long = repeat("word ", 100) let edited = gedcom_write([{ tag: "HEAD", children: [{ tag: "GEDC", children: [{ tag: "VERS", value: "5.5.1" }] }] }, { xref: "N1", tag: "NOTE", value: long + "\nmail a@b" }])? let lines = split(edited, "\n") { same: len(records) == len(again) && json.stringify(records) == json.stringify(again), escaped: contains(out, "ada@@example.org"), continued: slice(lines[3], 0, 6), longest: max(lines | map((l) => len(l))), last: lines[len(lines) - 3], back: len(gedcom_records(edited)?[1].value), }}route GET /broken { let bad = replace(replace(TEXT, "1 CHIL @I4@\n", "1 CHIL @I9@\n"), "0 TRLR\n", "") { problems: gedcom_check(bad)?.problems, clean: gedcom_check(TEXT)?, not_gedcom: gedcom_parse("hello").error, jump: gedcom_parse("0 HEAD\n2 VERS 5.5.1\n0 TRLR\n").error, nobody: gedcom_person(TEXT, "I99").error, }}$ quillo run gedcom.qwe$ curl 'localhost:8080/family'$ curl 'localhost:8080/person/I3'$ curl 'localhost:8080/tree'$ curl 'localhost:8080/related'$ curl 'localhost:8080/dates'$ curl 'localhost:8080/v7'$ curl 'localhost:8080/roundtrip'$ curl 'localhost:8080/broken'Source
//! GEDCOM 5.5.1 and 7 genealogy files: records read and written back, individuals and families with their dates, trees walked up and down, how two people are related, and broken links found.//! version: 1.0.0//!//! [dependencies]//! serde_json = { version = "1", features = ["preserve_order"] }use serde_json::{json, Map, Value};use std::collections::{HashMap, HashSet, VecDeque};const MAX_LINE_551: usize = 248;const MONTHS: [&str; 12] = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];const FRENCH: [&str; 13] = ["VEND", "BRUM", "FRIM", "NIVO", "PLUV", "VENT", "GERM", "FLOR", "PRAI", "MESS", "THER", "FRUC", "COMP"];const HEBREW: [&str; 13] = ["TSH", "CSH", "KSL", "TVT", "SHV", "ADR", "ADS", "NSN", "IYR", "SVN", "TMZ", "AAV", "ELL"];#[derive(Clone, Debug)]struct Node { xref: Option<String>, tag: String, value: String, kids: Vec<Node>, line: usize,}impl Node { fn kid(&self, tag: &str) -> Option<&Node> { self.kids.iter().find(|k| k.tag == tag) } fn val(&self, tag: &str) -> Option<&str> { self.kid(tag).map(|k| k.value.as_str()).filter(|v| !v.is_empty()) } fn all(&self, tag: &str) -> Vec<&Node> { self.kids.iter().filter(|k| k.tag == tag).collect() }}/// Reads the bytes of a file: a BOM says UTF-8 or UTF-16, else UTF-8, else ANSEL or ANSI bytes taken as Latin-1.fn decode(b: &[u8]) -> (String, Option<String>) { if b.starts_with(&[0xEF, 0xBB, 0xBF]) { return (String::from_utf8_lossy(&b[3..]).into_owned(), None); } if b.starts_with(&[0xFF, 0xFE]) || b.starts_with(&[0xFE, 0xFF]) { let big = b[0] == 0xFE; let u: Vec<u16> = b[2..].chunks_exact(2).map(|c| if big { u16::from_be_bytes([c[0], c[1]]) } else { u16::from_le_bytes([c[0], c[1]]) }).collect(); return (String::from_utf16_lossy(&u), None); } match std::str::from_utf8(b) { Ok(t) => (t.to_string(), None), Err(_) => (b.iter().map(|c| *c as char).collect(), Some("the file is not UTF-8: its bytes were read as Latin-1, and ANSEL's accents will be wrong".to_string())), }}/// `at` undone: 5.5.1 doubles every at sign, 7 only a leading one.fn unescape(v: &str, v7: bool) -> String { if v7 { return v.strip_prefix("@@").map_or_else(|| v.to_string(), |r| format!("@{}", r)); } v.replace("@@", "@")}fn escape(v: &str, v7: bool) -> String { if v7 { return if v.starts_with('@') { format!("@{}", v) } else { v.to_string() }; } v.replace('@', "@@")}fn is_pointer(v: &str) -> bool { v.len() > 2 && v.starts_with('@') && v.ends_with('@') && !v[1..v.len() - 1].contains('@')}fn read(text: &str) -> Result<(Vec<Node>, Vec<String>), String> { let mut problems = vec![]; let text = text.trim_start_matches('\u{feff}'); // The version first: it decides how an at sign is read. let v7 = text.lines().take(40).any(|l| { let t = l.trim(); t.starts_with("2 VERS 7") }); let mut roots: Vec<Node> = vec![]; let mut stack: Vec<Node> = vec![]; for (i, raw) in text.split(['\n', '\r']).enumerate().filter(|(_, l)| !l.trim().is_empty()) { let n = i + 1; let t = raw.trim_start(); let (level, rest) = t.split_once(' ').unwrap_or((t, "")); let Ok(level) = level.parse::<usize>() else { // Some programs wrap long notes with no level. if let Some(top) = stack.last_mut() { problems.push(format!("line {}: no level number, taken as the end of the line above", n)); top.value.push_str(raw); continue; } return Err(format!("line {}: `{}` does not start with a level number", n, raw.chars().take(30).collect::<String>())); }; if level > stack.len() { return Err(format!("line {}: level {} comes after level {}: a level may go up by one only", n, level, stack.len().saturating_sub(1))); } while stack.len() > level { let done = stack.pop().unwrap(); match stack.last_mut() { Some(p) => p.kids.push(done), None => roots.push(done), } } let (xref, rest) = if rest.starts_with('@') { let (x, r) = rest.split_once(' ').unwrap_or((rest, "")); if x.ends_with('@') && x.len() > 2 { (Some(x.trim_matches('@').to_string()), r) } else { (None, rest) } } else { (None, rest) }; let (tag, value) = rest.split_once(' ').unwrap_or((rest, "")); if tag.is_empty() { return Err(format!("line {}: no tag", n)); } let value = if is_pointer(value) { value.to_string() } else { unescape(value, v7) }; stack.push(Node { xref, tag: tag.to_string(), value, kids: vec![], line: n }); } while let Some(done) = stack.pop() { match stack.last_mut() { Some(p) => p.kids.push(done), None => roots.push(done), } } if roots.first().map(|r| r.tag.as_str()) != Some("HEAD") { return Err("line 1: a GEDCOM file starts with 0 HEAD".into()); } if roots.last().map(|r| r.tag.as_str()) != Some("TRLR") { problems.push("the file has no 0 TRLR: it may be cut short".to_string()); } for r in &mut roots { join(r); } Ok((roots, problems))}/// Folds CONC and CONT into their line's value, at every depth.fn join(n: &mut Node) { let mut kept = vec![]; for mut k in std::mem::take(&mut n.kids) { match k.tag.as_str() { "CONC" => n.value.push_str(&k.value), "CONT" => { n.value.push('\n'); n.value.push_str(&k.value); } _ => { join(&mut k); kept.push(k); } } } n.kids = kept;}fn version(roots: &[Node]) -> String { roots.first().and_then(|h| h.kid("GEDC")).and_then(|g| g.val("VERS")).unwrap_or("5.5.1").to_string()}fn month_number(m: &str, cal: &str) -> Option<u32> { let list: &[&str] = match cal { "FRENCH_R" => &FRENCH, "HEBREW" => &HEBREW, _ => &MONTHS, }; list.iter().position(|x| x.eq_ignore_ascii_case(m)).map(|i| i as u32 + 1)}fn one_date(t: &str, default_cal: &str) -> Value { let mut words: Vec<&str> = t.split_whitespace().collect(); let mut cal = default_cal.to_string(); if let Some(first) = words.first().copied() { let c = first.trim_start_matches("@#D").trim_end_matches('@').replace(' ', "_"); if first.starts_with("@#D") { words.remove(0); if c == "FRENCH" && words.first() == Some(&"R@") { words.remove(0); cal = "FRENCH_R".to_string(); } else { cal = c; } } else if ["GREGORIAN", "JULIAN", "HEBREW", "FRENCH_R"].contains(&first) { cal = first.to_string(); words.remove(0); } } let mut bce = false; if let Some(l) = words.last() { if ["B.C.", "BC", "BCE"].contains(l) { bce = true; words.pop(); } } if words.first() == Some(&"BCE") { bce = true; words.remove(0); } let (mut day, mut month, mut year) = (None, None, None); match words.as_slice() { [y] => year = y.split('/').next().and_then(|x| x.parse::<i64>().ok()), [m, y] => { month = month_number(m, &cal); year = y.split('/').next().and_then(|x| x.parse::<i64>().ok()); } [d, m, y] => { day = d.parse::<u32>().ok(); month = month_number(m, &cal); year = y.split('/').next().and_then(|x| x.parse::<i64>().ok()); } _ => {} } let year = year.map(|y| if bce { -y } else { y }); json!({ "calendar": cal, "year": year, "month": month, "day": day })}/// Reads a GEDCOM date: `{ text, kind, calendar, year, month, day, to, phrase, sort }`. `kind` is exact, about, calculated, estimated, before, after, between, from, to, period, interpreted or phrase; `to` holds the second date of a range; `sort` is a whole number for ordering, year then month then day. Takes `ABT 1850`, `BET 1822 AND 1825`, `FROM 1880 TO 1881`, `@#DJULIAN@ 2 FEB 1852`, and 7's `JULIAN 1 JAN 1992` and `BCE`.pub fn gedcom_date(text: &str) -> Value { let t = text.trim(); let mut kind = "exact"; let mut body = t; let mut to: Option<&str> = None; let mut phrase = Value::Null; if t.starts_with('(') && t.ends_with(')') { return json!({ "text": t, "kind": "phrase", "calendar": null, "year": null, "month": null, "day": null, "to": null, "phrase": &t[1..t.len() - 1], "sort": null }); } if let Some(open) = t.find('(') { if t.starts_with("INT ") { phrase = json!(t[open + 1..].trim_end_matches(')')); body = t[4..open].trim(); } } for (word, name) in [("ABT ", "about"), ("CAL ", "calculated"), ("EST ", "estimated"), ("BEF ", "before"), ("AFT ", "after"), ("INT ", "interpreted")] { if let Some(r) = body.strip_prefix(word) { kind = name; body = r; } } if t.starts_with("INT ") { kind = "interpreted"; } if let Some(r) = body.strip_prefix("BET ") { kind = "between"; match r.split_once(" AND ") { Some((a, b)) => { body = a; to = Some(b); } None => body = r, } } else if let Some(r) = body.strip_prefix("FROM ") { match r.split_once(" TO ") { Some((a, b)) => { kind = "period"; body = a; to = Some(b); } None => { kind = "from"; body = r; } } } else if let Some(r) = body.strip_prefix("TO ") { kind = "to"; body = r; } let a = one_date(body, "GREGORIAN"); let sort = a["year"].as_i64().map(|y| y * 10000 + a["month"].as_i64().unwrap_or(0) * 100 + a["day"].as_i64().unwrap_or(0)); json!({ "text": t, "kind": kind, "calendar": a["calendar"], "year": a["year"], "month": a["month"], "day": a["day"], "to": to.map(|x| one_date(x, "GREGORIAN")), "phrase": phrase, "sort": sort })}fn event(n: &Node) -> Value { let date = n.kid("DATE").map(|d| { let mut v = gedcom_date(&d.value); if let Some(p) = d.val("PHRASE") { v["phrase"] = json!(p); } v }); json!({ "type": n.tag, "date": date, "place": n.val("PLAC"), "note": n.val("NOTE"), "value": if n.value.is_empty() { Value::Null } else { json!(n.value) } })}fn name_of(n: &Node) -> Value { let (given, surname, suffix) = match (n.value.find('/'), n.value.rfind('/')) { (Some(a), Some(b)) if b > a => (n.value[..a].trim(), &n.value[a + 1..b], n.value[b + 1..].trim()), _ => (n.value.trim(), "", ""), }; let full = [given, surname, suffix].iter().filter(|x| !x.is_empty()).copied().collect::<Vec<_>>().join(" "); json!({ "full": full, "given": n.val("GIVN").unwrap_or(given), "surname": n.val("SURN").unwrap_or(surname), "suffix": n.val("NSFX").unwrap_or(suffix), "nickname": n.val("NICK") })}const INDI_EVENTS: &[&str] = &["BIRT", "CHR", "DEAT", "BURI", "CREM", "ADOP", "BAPM", "BARM", "BASM", "CONF", "FCOM", "GRAD", "EMIG", "IMMI", "NATU", "CENS", "PROB", "WILL", "RETI", "EVEN", "RESI", "OCCU", "EDUC", "RELI", "TITL"];const FAM_EVENTS: &[&str] = &["MARR", "DIV", "ANUL", "ENGA", "MARB", "MARL", "MARS", "CENS", "EVEN", "RESI"];fn id(v: &str) -> String { v.trim_matches('@').to_string()}fn person(n: &Node) -> Value { let name = n.kid("NAME").map(name_of).unwrap_or_else(|| json!({ "full": "" })); let events: Vec<Value> = n.kids.iter().filter(|k| INDI_EVENTS.contains(&k.tag.as_str())).map(event).collect(); let date = |t: &str| n.kid(t).map(event).unwrap_or(Value::Null); let names: Vec<Value> = n.all("NAME").iter().map(|x| name_of(x)).collect(); json!({ "id": n.xref, "name": name["full"], "given": name["given"], "surname": name["surname"], "names": names, "sex": n.val("SEX"), "birth": date("BIRT"), "death": date("DEAT"), "events": events, "parents_in": n.all("FAMC").iter().map(|x| id(&x.value)).collect::<Vec<_>>(), "spouse_in": n.all("FAMS").iter().map(|x| id(&x.value)).collect::<Vec<_>>(), "note": n.val("NOTE"), })}fn family(n: &Node) -> Value { let events: Vec<Value> = n.kids.iter().filter(|k| FAM_EVENTS.contains(&k.tag.as_str())).map(event).collect(); let date = |t: &str| n.kid(t).map(event).unwrap_or(Value::Null); json!({ "id": n.xref, "husband": n.val("HUSB").map(id), "wife": n.val("WIFE").map(id), "children": n.all("CHIL").iter().map(|x| id(&x.value)).collect::<Vec<_>>(), "marriage": date("MARR"), "events": events, })}fn tree_json(n: &Node) -> Value { let mut m = Map::new(); if let Some(x) = &n.xref { m.insert("xref".into(), json!(x)); } m.insert("tag".into(), json!(n.tag)); if !n.value.is_empty() { m.insert("value".into(), json!(n.value)); } if !n.kids.is_empty() { m.insert("children".into(), Value::Array(n.kids.iter().map(tree_json).collect())); } Value::Object(m)}fn tree_back(v: &Value) -> Result<Node, String> { Ok(Node { xref: v["xref"].as_str().map(str::to_string), tag: v["tag"].as_str().ok_or("a record has no tag")?.to_string(), value: v["value"].as_str().unwrap_or("").to_string(), kids: v["children"].as_array().map_or(Ok(vec![]), |a| a.iter().map(tree_back).collect::<Result<_, _>>())?, line: 0, })}/// A file's text as a whole: `{ version, encoding, source, individuals, families, counts, problems }`, CONC and CONT folded, at signs undone.pub fn gedcom_parse(text: &str) -> Result<Value, String> { let (roots, mut problems) = read(text)?; let head = &roots[0]; let individuals: Vec<Value> = roots.iter().filter(|r| r.tag == "INDI").map(person).collect(); let families: Vec<Value> = roots.iter().filter(|r| r.tag == "FAM").map(family).collect(); problems.extend(links(&roots)); Ok(json!({ "version": version(&roots), "encoding": head.val("CHAR").unwrap_or("UTF-8"), "source": head.val("SOUR"), "note": head.val("NOTE"), "individuals": individuals, "families": families, "counts": { "individuals": roots.iter().filter(|r| r.tag == "INDI").count(), "families": roots.iter().filter(|r| r.tag == "FAM").count(), "sources": roots.iter().filter(|r| r.tag == "SOUR").count() }, "problems": problems, }))}/// Reads a file from disk as `gedcom_parse` does, working out its encoding.pub fn gedcom_read_file(path: &str) -> Result<Value, String> { let b = std::fs::read(path).map_err(|e| format!("{}: {}", path, e))?; let (text, warning) = decode(&b); let mut v = gedcom_parse(&text)?; if let Some(w) = warning { v["problems"].as_array_mut().unwrap().insert(0, json!(w)); } Ok(v)}/// Broken links: pointers to nothing, and families and people who disagree. 7's `@VOID@` is no pointer.fn links(roots: &[Node]) -> Vec<String> { let mut p = vec![]; let known: HashSet<&str> = roots.iter().filter_map(|r| r.xref.as_deref()).collect(); let by: HashMap<&str, &Node> = roots.iter().filter_map(|r| r.xref.as_deref().map(|x| (x, r))).collect(); fn walk<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) { f(n); for k in &n.kids { walk(k, f); } } for r in roots { walk(r, &mut |n| { if is_pointer(&n.value) { let t = id(&n.value); if t != "VOID" && !known.contains(t.as_str()) { p.push(format!("line {}: {} points to @{}@, which is not in the file", n.line, n.tag, t)); } } }); let me = r.xref.as_deref().unwrap_or(""); if r.tag == "INDI" { for (tag, back) in [("FAMC", &["CHIL"][..]), ("FAMS", &["HUSB", "WIFE"][..])] { for f in r.all(tag) { if let Some(fam) = by.get(id(&f.value).as_str()) { if !back.iter().any(|b| fam.all(b).iter().any(|k| id(&k.value) == me)) { p.push(format!("line {}: {} says @{}@ is in family @{}@, and the family does not name them", f.line, tag, me, id(&f.value))); } } } } } if r.tag == "FAM" { for (tag, back) in [("CHIL", "FAMC"), ("HUSB", "FAMS"), ("WIFE", "FAMS")] { for k in r.all(tag) { if let Some(i) = by.get(id(&k.value).as_str()) { if !i.all(back).iter().any(|f| id(&f.value) == me) { p.push(format!("line {}: family @{}@ names @{}@ as {}, and that person does not name the family", k.line, me, id(&k.value), tag)); } } } } } } p}/// What is wrong with a file: `{ ok, problems }`, pointers to nothing and links that go one way, by line.pub fn gedcom_check(text: &str) -> Result<Value, String> { let (roots, mut problems) = read(text)?; problems.extend(links(&roots)); Ok(json!({ "ok": problems.is_empty(), "problems": problems }))}struct Tree<'a> { people: HashMap<&'a str, &'a Node>, fams: HashMap<&'a str, &'a Node>,}fn index(roots: &[Node]) -> Tree<'_> { let by = |t: &'static str| roots.iter().filter(|r| r.tag == t).filter_map(|r| r.xref.as_deref().map(|x| (x, r))).collect(); Tree { people: by("INDI"), fams: by("FAM") }}impl Tree<'_> { fn parents(&self, who: &str) -> Vec<String> { let Some(p) = self.people.get(who) else { return vec![] }; p.all("FAMC").iter().filter_map(|f| self.fams.get(id(&f.value).as_str())).flat_map(|f| f.kids.iter().filter(|k| k.tag == "HUSB" || k.tag == "WIFE").map(|k| id(&k.value))).collect() } fn children(&self, who: &str) -> Vec<String> { let Some(p) = self.people.get(who) else { return vec![] }; p.all("FAMS").iter().filter_map(|f| self.fams.get(id(&f.value).as_str())).flat_map(|f| f.all("CHIL").iter().map(|k| id(&k.value)).collect::<Vec<_>>()).collect() } fn spouses(&self, who: &str) -> Vec<String> { let Some(p) = self.people.get(who) else { return vec![] }; p.all("FAMS").iter().filter_map(|f| self.fams.get(id(&f.value).as_str())).flat_map(|f| f.kids.iter().filter(|k| (k.tag == "HUSB" || k.tag == "WIFE") && id(&k.value) != who).map(|k| id(&k.value)).collect::<Vec<_>>()).collect() } fn name(&self, who: &str) -> Value { self.people.get(who).and_then(|p| p.kid("NAME")).map(name_of).map_or(Value::Null, |n| n["full"].clone()) } /// All reached by `step`, each with its distance, nearest first; a loop in a bad file does not hang. fn walk(&self, start: &str, most: usize, step: impl Fn(&str) -> Vec<String>) -> Vec<(String, usize)> { let mut seen: HashSet<String> = HashSet::from([start.to_string()]); let mut q = VecDeque::from([(start.to_string(), 0usize)]); let mut out = vec![]; while let Some((x, d)) = q.pop_front() { if d >= most { continue; } for n in step(&x) { if seen.insert(n.clone()) { out.push((n.clone(), d + 1)); q.push_back((n, d + 1)); } } } out }}fn who_known<'a>(t: &Tree<'a>, who: &str) -> Result<(), String> { if t.people.contains_key(who) { Ok(()) } else { Err(format!("nobody with id {}", who)) }}/// One person's family: `{ person, parents, spouses, children, siblings }`.pub fn gedcom_person(text: &str, who: &str) -> Result<Value, String> { let (roots, _) = read(text)?; let t = index(&roots); let who = id(who); who_known(&t, &who)?; let names = |list: Vec<String>| list.iter().map(|i| json!({ "id": i, "name": t.name(i) })).collect::<Vec<_>>(); let mut sib: Vec<String> = t.parents(&who).iter().flat_map(|p| t.children(p)).filter(|c| *c != who).collect(); let mut seen = HashSet::new(); sib.retain(|s| seen.insert(s.clone())); Ok(json!({ "person": person(t.people[who.as_str()]), "parents": names(t.parents(&who)), "spouses": names(t.spouses(&who)), "children": names(t.children(&who)), "siblings": names(sib) }))}/// Everyone above someone, up to `generations` (0 for all): `[{ id, name, generation }]`, parents being generation 1.pub fn gedcom_ancestors(text: &str, who: &str, generations: i64) -> Result<Vec<Value>, String> { let (roots, _) = read(text)?; let t = index(&roots); let who = id(who); who_known(&t, &who)?; let most = if generations <= 0 { usize::MAX } else { generations as usize }; Ok(t.walk(&who, most, |x| t.parents(x)).iter().map(|(i, g)| json!({ "id": i, "name": t.name(i), "generation": g })).collect())}/// Everyone below someone, likewise.pub fn gedcom_descendants(text: &str, who: &str, generations: i64) -> Result<Vec<Value>, String> { let (roots, _) = read(text)?; let t = index(&roots); let who = id(who); who_known(&t, &who)?; let most = if generations <= 0 { usize::MAX } else { generations as usize }; Ok(t.walk(&who, most, |x| t.children(x)).iter().map(|(i, g)| json!({ "id": i, "name": t.name(i), "generation": g })).collect())}fn relation_name(up: usize, down: usize) -> String { let greats = |n: usize, word: &str| if n <= 1 { word.to_string() } else { format!("{}{}", "great-".repeat(n - 1), word) }; match (up, down) { (0, 0) => "the same person".into(), (1, 0) => "parent".into(), (0, 1) => "child".into(), (n, 0) => greats(n - 1, "grandparent"), (0, n) => greats(n - 1, "grandchild"), (1, 1) => "sibling".into(), (n, 1) => greats(n - 1, "aunt or uncle"), (1, n) => greats(n - 1, "niece or nephew"), (a, b) => { let (deg, gap) = (a.min(b) - 1, a.abs_diff(b)); let ord = match deg { 1 => "first".to_string(), 2 => "second".into(), 3 => "third".into(), n => format!("{}th", n) }; if gap == 0 { format!("{} cousin", ord) } else { format!("{} cousin {} {} removed", ord, gap, if gap == 1 { "time" } else { "times" }) } } }}/// How `a` is related to `b` through the nearest common ancestor: `{ relation, common_ancestor, up, down }`, `relation` being what `a` is to `b`, as "great-grandparent", "first cousin 1 time removed"; `up` and `down` the steps from `b` to the ancestor and from there to `a`. Null when they share no ancestor in the file.pub fn gedcom_relationship(text: &str, a: &str, b: &str) -> Result<Value, String> { let (roots, _) = read(text)?; let t = index(&roots); let (a, b) = (id(a), id(b)); who_known(&t, &a)?; who_known(&t, &b)?; let mut up_a: HashMap<String, usize> = t.walk(&a, usize::MAX, |x| t.parents(x)).into_iter().collect(); up_a.insert(a.clone(), 0); let mut up_b: Vec<(String, usize)> = t.walk(&b, usize::MAX, |x| t.parents(x)); up_b.insert(0, (b.clone(), 0)); let best = up_b.iter().filter_map(|(x, db)| up_a.get(x).map(|da| (x.clone(), *da, *db))).min_by_key(|x| x.1 + x.2); Ok(match best { None => Value::Null, // From `b`, `a` is reached by going up `db` to the ancestor and down `da`. Some((x, da, db)) => json!({ "relation": relation_name(db, da), "common_ancestor": { "id": x, "name": t.name(&x) }, "up": db, "down": da }), })}/// The records as a plain tree of `{ xref, tag, value, children }`, each with CONC and CONT folded: what `gedcom_write` takes back, for a program that edits the file.pub fn gedcom_records(text: &str) -> Result<Vec<Value>, String> { let (roots, _) = read(text)?; Ok(roots.iter().map(tree_json).collect())}fn write_node(n: &Node, level: usize, v7: bool, out: &mut String) { let head = match &n.xref { Some(x) => format!("{} @{}@ {}", level, x, n.tag), None => format!("{} {}", level, n.tag), }; let mut lines = n.value.split('\n'); let first = lines.next().unwrap_or(""); let pointer = is_pointer(first); let line = |x: &str| if pointer { x.to_string() } else { escape(x, v7) }; let mut put = |prefix: String, text: String, kid_level: usize, out: &mut String| { if v7 || text.chars().count() + prefix.len() + 1 <= MAX_LINE_551 { out.push_str(&prefix); if !text.is_empty() { out.push(' '); out.push_str(&text); } out.push('\n'); return; } // 5.5.1's lines stop at 255: the rest goes in CONC, never split at a space or inside an @@. let chars: Vec<char> = text.chars().collect(); let mut i = 0; let mut pre = prefix; while i < chars.len() { let room = 200.min(chars.len() - i); let mut end = i + room; while end < chars.len() && end > i + 1 && (chars[end] == ' ' || chars[end - 1] == ' ' || chars[end] == '@' || chars[end - 1] == '@') { end -= 1; } out.push_str(&pre); out.push(' '); out.extend(&chars[i..end]); out.push('\n'); i = end; pre = format!("{} CONC", kid_level); } }; put(head, line(first), level + 1, out); for extra in lines { put(format!("{} CONT", level + 1), line(extra), level + 1, out); } for k in &n.kids { write_node(k, level + 1, v7, out); }}/// Writes records back as GEDCOM text: `records` as `gedcom_records` gives them. `@` is doubled as the version in `HEAD` says, long lines of 5.5.1 go on in CONC, line breaks in CONT.pub fn gedcom_write(records: Vec<serde_json::Value>) -> Result<String, String> { let roots: Vec<Node> = records.iter().map(tree_back).collect::<Result<_, _>>()?; if roots.first().map(|r| r.tag.as_str()) != Some("HEAD") { return Err("the first record must be HEAD".into()); } let v7 = version(&roots).starts_with('7'); let mut out = String::new(); for r in &roots { write_node(r, 0, v7, &mut out); } if roots.last().map(|r| r.tag.as_str()) != Some("TRLR") { out.push_str("0 TRLR\n"); } Ok(out)}Related
avro.qwer · 1.0.0Apache Avro data files: rows written with a schema, as Kafka, Hadoop and BigQuery use, and read back with the schema they carry.9 views · 6 downloadsBibTeX bibtex.qwer · 1.0.0BibTeX and BibLaTeX bibliographies read, with LaTeX accents and macros resolved; reference lists in APA or IEEE style, CSL-JSON for Zotero and pandoc, and entries written back.7 views · 6 downloadsBSON bson.qwer · 1.0.0BSON, MongoDB's binary format: documents to BSON and back as Extended JSON, and ObjectIds made and read.6 views · 6 downloadsCBOR cbor.qwer · 1.0.0CBOR (RFC 8949), the binary format of passkeys, COSE and IoT devices: values to CBOR and back, as base64 or files.7 views · 7 downloadsCSV csv.qwer · 1.0.0CSV in and out: rows as maps keyed by the header, with quotes, commas and line breaks in cells handled.8 views · 8 downloadsCSV sniffer csv_sniff.qwer · 1.0.0Guess how an uploaded CSV is written before importing it: delimiter, quote, header row, decimal commas and line endings.9 views · 7 downloadsThread 0 posts
On GEDCOM genealogy files: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.