gedcomkit 0.1.12__tar.gz → 0.1.13__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/CHANGELOG.md +20 -0
  2. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/Cargo.lock +1 -1
  3. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/Cargo.toml +1 -1
  4. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/PKG-INFO +1 -1
  5. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/bindings/gedcomkit-py/Cargo.lock +2 -2
  6. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/bindings/gedcomkit-py/Cargo.toml +1 -1
  7. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/src/convert.rs +278 -0
  8. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/LICENSE +0 -0
  9. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/README.md +0 -0
  10. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/bindings/gedcomkit-py/LICENSE +0 -0
  11. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/bindings/gedcomkit-py/README.md +0 -0
  12. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/bindings/gedcomkit-py/gedcomkit.pyi +0 -0
  13. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/bindings/gedcomkit-py/src/lib.rs +0 -0
  14. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom-spec/LICENSE +0 -0
  15. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom-spec/README.md +0 -0
  16. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom-spec/cardinalities.tsv +0 -0
  17. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom-spec/enumerations.tsv +0 -0
  18. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom-spec/enumerationsets.tsv +0 -0
  19. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom-spec/grammar.abnf +0 -0
  20. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom-spec/grammar.gedstruct +0 -0
  21. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom-spec/payloads.tsv +0 -0
  22. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom-spec/substructures.tsv +0 -0
  23. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/age-invalid.ged +0 -0
  24. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/age-valid.ged +0 -0
  25. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/atsign.ged +0 -0
  26. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/char_ascii_1.ged +0 -0
  27. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/char_ascii_2.ged +0 -0
  28. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/char_utf16be-1.ged +0 -0
  29. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/char_utf16be-2.ged +0 -0
  30. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/char_utf16le-1.ged +0 -0
  31. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/char_utf16le-2.ged +0 -0
  32. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/char_utf8-1.ged +0 -0
  33. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/char_utf8-2.ged +0 -0
  34. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/char_utf8-3.ged +0 -0
  35. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/date-all.ged +0 -0
  36. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/date-dual-invalid.ged +0 -0
  37. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/date-dual-valid.ged +0 -0
  38. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/enum-ext.ged +0 -0
  39. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/filename-1.ged +0 -0
  40. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/lang-all.ged +0 -0
  41. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/notes-1.ged +0 -0
  42. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/obje-1.ged +0 -0
  43. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/obsolete-1.ged +0 -0
  44. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/pedi-1.ged +0 -0
  45. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/rela_1.ged +0 -0
  46. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/sour-1.ged +0 -0
  47. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/tiny-1.ged +0 -0
  48. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/5/xref-case.ged +0 -0
  49. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/age-invalid.ged +0 -0
  50. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/age-valid.ged +0 -0
  51. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/atsign.ged +0 -0
  52. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/char_ascii_1.ged +0 -0
  53. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/char_ascii_2.ged +0 -0
  54. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/char_utf16be-1.ged +0 -0
  55. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/char_utf16be-2.ged +0 -0
  56. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/char_utf16le-1.ged +0 -0
  57. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/char_utf16le-2.ged +0 -0
  58. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/char_utf8-1.ged +0 -0
  59. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/char_utf8-2.ged +0 -0
  60. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/char_utf8-3.ged +0 -0
  61. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/date-all.ged +0 -0
  62. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/date-dual-invalid.ged +0 -0
  63. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/enum-ext.ged +0 -0
  64. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/filename-1.ged +0 -0
  65. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/lang-all.ged +0 -0
  66. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/notes-1.ged +0 -0
  67. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/obje-1.ged +0 -0
  68. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/obsolete-1.ged +0 -0
  69. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/pedi-1.ged +0 -0
  70. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/rela_1.ged +0 -0
  71. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/sour-1.ged +0 -0
  72. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/tiny-1.ged +0 -0
  73. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/7/xref-case.ged +0 -0
  74. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/LICENSE +0 -0
  75. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/gedcom7code/UPSTREAM-README.md +0 -0
  76. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/zip-interop/README.md +0 -0
  77. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/zip-interop/python-zip64.gdz +0 -0
  78. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/fixtures/vendored/zip-interop/python-zipfile.gdz +0 -0
  79. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/pyproject.toml +0 -0
  80. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/src/build.rs +0 -0
  81. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/src/dates.rs +0 -0
  82. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/src/decode.rs +0 -0
  83. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/src/family.rs +0 -0
  84. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/src/gedzip.rs +0 -0
  85. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/src/lib.rs +0 -0
  86. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/src/names.rs +0 -0
  87. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/src/schema.rs +0 -0
  88. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/src/synthetic.rs +0 -0
  89. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/src/tags.rs +0 -0
  90. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/src/validate.rs +0 -0
  91. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/src/view.rs +0 -0
  92. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/tests/endurance.rs +0 -0
  93. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/tests/external_charset.rs +0 -0
  94. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/tests/properties.rs +0 -0
  95. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/tests/requirement_corpus.rs +0 -0
  96. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/tests/spec_conformance.rs +0 -0
  97. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/tests/typescript.rs +0 -0
  98. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/tests/vendored_corpus.rs +0 -0
  99. {gedcomkit-0.1.12 → gedcomkit-0.1.13}/tests/version_conversion.rs +0 -0
@@ -5,6 +5,26 @@ format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
5
5
  the project uses semantic versioning within the usual pre-1.0 compatibility
6
6
  rules.
7
7
 
8
+ ## [0.1.13] - 2026-10-02
9
+
10
+ ### Fixed
11
+
12
+ - `convert::to_version_7` / `to_version_7_with` now convert roles. A role in
13
+ a cited event (`SOUR`.`EVEN`.`ROLE`) that 5.5.1 writes as free text —
14
+ its `(descriptor)` form, or a bare word — becomes version 7's enumerated
15
+ role it names (`Son` → `CHIL`, `Godmother` → `GODP`, `Witness` → `WITN`;
16
+ `OTHER` when it names none), with the words kept verbatim as a `PHRASE`;
17
+ an enumerated value in another case is upper-cased. An association's
18
+ `RELA`, which version 7 spells `ROLE`, gets the same treatment. Before, both
19
+ were carried into the 7.0 document unchanged, where neither is valid. A
20
+ role that already has a `PHRASE`, is empty, or has a continuation line with
21
+ substructures is kept and reported.
22
+ - `convert::to_version_5_5_1` turns them back: under a cited event a role
23
+ 5.5.1 enumerates (`CHIL`, `HUSB`, `WIFE`, `MOTH`, `FATH`, `SPOU`) without a
24
+ phrase stays, and any other becomes `(words)` — the `PHRASE` when there is
25
+ one, the role in words otherwise; under an association the role becomes
26
+ `RELA` with the same words.
27
+
8
28
  ## [0.1.12] - 2026-09-30
9
29
 
10
30
  ### Fixed
@@ -35,7 +35,7 @@ dependencies = [
35
35
 
36
36
  [[package]]
37
37
  name = "gedcomkit"
38
- version = "0.1.12"
38
+ version = "0.1.13"
39
39
  dependencies = [
40
40
  "flate2",
41
41
  "serde",
@@ -3,7 +3,7 @@
3
3
  # was checked on crates.io, npm, and PyPI before the initial release.
4
4
  [package]
5
5
  name = "gedcomkit"
6
- version = "0.1.12"
6
+ version = "0.1.13"
7
7
  description = "A byte-preserving GEDCOM document model: decoding, parsing, readings, version conversion, plausibility checks, and the GEDZIP container, for GEDCOM 5.5 through 7.x."
8
8
  edition = "2024"
9
9
  # The true minimum, not the workspace's toolchain: a parser library is a leaf
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gedcomkit
3
- Version: 0.1.12
3
+ Version: 0.1.13
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: Programming Language :: Python :: Implementation :: CPython
@@ -35,7 +35,7 @@ dependencies = [
35
35
 
36
36
  [[package]]
37
37
  name = "gedcomkit"
38
- version = "0.1.12"
38
+ version = "0.1.13"
39
39
  dependencies = [
40
40
  "flate2",
41
41
  "serde",
@@ -43,7 +43,7 @@ dependencies = [
43
43
 
44
44
  [[package]]
45
45
  name = "gedcomkit-py"
46
- version = "0.1.12"
46
+ version = "0.1.13"
47
47
  dependencies = [
48
48
  "gedcomkit",
49
49
  "pyo3",
@@ -2,7 +2,7 @@
2
2
  # abi3 keeps it to one wheel per platform across Python versions.
3
3
  [package]
4
4
  name = "gedcomkit-py"
5
- version = "0.1.12"
5
+ version = "0.1.13"
6
6
  description = "Python bindings for gedcomkit: an archival-grade GEDCOM library — byte-preserving round-trip, encoding detection, GEDCOM 5.5 through 7.x, GEDZIP."
7
7
  edition = "2024"
8
8
  rust-version = "1.88"
@@ -324,6 +324,18 @@ fn structures_to_7_node(node: &mut Node, shared: &[String], pass: &mut Pass) ->
324
324
  changed |= date_to_7(node, pass);
325
325
  }
326
326
 
327
+ // A role in a cited event (`SOUR`.`EVEN`.`ROLE`), and an association's
328
+ // relation, which 5.5.1 spells `RELA` and version 7 `ROLE`: both become
329
+ // version 7's enumerated role, the words as written kept in a `PHRASE`.
330
+ if node.tag == "EVEN" || node.tag == "ASSO" {
331
+ let association = node.tag == "ASSO";
332
+ for child in &mut node.children {
333
+ if child.tag == "ROLE" || (association && child.tag == "RELA") {
334
+ changed |= role_to_7(child, pass);
335
+ }
336
+ }
337
+ }
338
+
327
339
  // A multimedia record's files: `FORM` moves from the object to each
328
340
  // `FILE` (5.5), and each file's path, format and medium take version 7's
329
341
  // shape (5.5.1 already nests `FORM` under `FILE`, with `TYPE` beneath).
@@ -845,6 +857,201 @@ fn medium_to_7(medium: &str) -> (String, bool) {
845
857
  }
846
858
  }
847
859
 
860
+ /// Version 7's enumerated roles (`enumset-ROLE`).
861
+ const ROLES_7: &[&str] = &[
862
+ "CHIL",
863
+ "CLERGY",
864
+ "FATH",
865
+ "FRIEND",
866
+ "GODP",
867
+ "HUSB",
868
+ "MOTH",
869
+ "MULTIPLE",
870
+ "NGHBR",
871
+ "OFFICIATOR",
872
+ "PARENT",
873
+ "SPOU",
874
+ "WIFE",
875
+ "WITN",
876
+ "OTHER",
877
+ ];
878
+
879
+ /// The enumerated role a word for one reads as, and `OTHER` when none does.
880
+ /// Only a word that names the role outright counts: a son is a child, a
881
+ /// godmother a godparent; anything else is `OTHER`, and the words survive in
882
+ /// the `PHRASE` either way.
883
+ fn role_word_to_7(words: &str) -> &'static str {
884
+ match words.trim().to_lowercase().as_str() {
885
+ "child" | "son" | "daughter" => "CHIL",
886
+ "husband" => "HUSB",
887
+ "wife" => "WIFE",
888
+ "mother" => "MOTH",
889
+ "father" => "FATH",
890
+ "spouse" => "SPOU",
891
+ "parent" => "PARENT",
892
+ "friend" => "FRIEND",
893
+ "godparent" | "godfather" | "godmother" => "GODP",
894
+ "witness" => "WITN",
895
+ "neighbor" | "neighbour" => "NGHBR",
896
+ "clergy" | "priest" | "minister" | "pastor" => "CLERGY",
897
+ "officiator" | "officiant" => "OFFICIATOR",
898
+ "multiple" => "MULTIPLE",
899
+ _ => "OTHER",
900
+ }
901
+ }
902
+
903
+ /// A 5.5.1 role (`ROLE` under a cited event, or an association's `RELA`) in
904
+ /// version 7's shape. A value already in the enumeration stays; free text —
905
+ /// 5.5.1's `(descriptor)` form, or any other word — becomes the enumerated
906
+ /// role it names (`OTHER` when it names none) with the text kept verbatim as
907
+ /// a `PHRASE`.
908
+ fn role_to_7(role: &mut Node, pass: &mut Pass) -> bool {
909
+ let from_rela = role.tag == "RELA";
910
+ let original = role.logical_value();
911
+ let trimmed = original.trim();
912
+ let upper = trimmed.to_ascii_uppercase();
913
+ let label = if from_rela { "ASSO.RELA" } else { "ROLE" };
914
+
915
+ if !from_rela && ROLES_7.contains(&upper.as_str()) {
916
+ if upper == original {
917
+ return false;
918
+ }
919
+ if continuation_has_children(role) {
920
+ refuse_for_continuation_children(label, "role", pass);
921
+ return false;
922
+ }
923
+ role.set_logical_value(&upper);
924
+ pass.note(
925
+ label,
926
+ ConversionKind::Converted,
927
+ format!("the role {original:?} is version 7's enumerated value {upper}"),
928
+ );
929
+ return true;
930
+ }
931
+
932
+ let words = trimmed
933
+ .strip_prefix('(')
934
+ .and_then(|inner| inner.strip_suffix(')'))
935
+ .unwrap_or(trimmed)
936
+ .trim()
937
+ .to_owned();
938
+ if continuation_has_children(role) {
939
+ refuse_for_continuation_children(label, "role", pass);
940
+ return false;
941
+ }
942
+ if words.is_empty() {
943
+ pass.note(
944
+ label,
945
+ ConversionKind::Kept,
946
+ "an empty role has no version 7 reading; it is left as it is",
947
+ );
948
+ return false;
949
+ }
950
+ if role.first("PHRASE").is_some() {
951
+ pass.note(
952
+ label,
953
+ ConversionKind::Kept,
954
+ format!(
955
+ "the role {original:?} is not version 7's enumeration, but it already has a PHRASE to keep the words in; it is left as it is"
956
+ ),
957
+ );
958
+ return false;
959
+ }
960
+ let value = role_word_to_7(&words);
961
+ if from_rela {
962
+ role.set_tag("ROLE");
963
+ }
964
+ role.set_logical_value(value);
965
+ role.push(Node::with_value("PHRASE", &words));
966
+ pass.note(
967
+ label,
968
+ ConversionKind::Converted,
969
+ if from_rela {
970
+ format!(
971
+ "an association's relation {words:?} is version 7's ROLE {value}, with the words kept as a PHRASE"
972
+ )
973
+ } else {
974
+ format!("the role {original:?} became {value}, with the words kept as a PHRASE")
975
+ },
976
+ );
977
+ true
978
+ }
979
+
980
+ /// The roles 5.5.1 enumerates under a cited event.
981
+ const ROLES_5: &[&str] = &["CHIL", "HUSB", "WIFE", "MOTH", "FATH", "SPOU"];
982
+
983
+ /// A version 7 role in 5.5.1's spelling. Under an association it becomes
984
+ /// `RELA`, whose payload is free text; under a cited event, a role 5.5.1
985
+ /// enumerates stays and any other becomes `(descriptor)`. A `PHRASE` is the
986
+ /// words to keep, and leaves with them.
987
+ fn role_to_5(role: &mut Node, association: bool, pass: &mut Pass) -> bool {
988
+ let enumerated = role.logical_value().trim().to_ascii_uppercase();
989
+ let phrase = role
990
+ .first("PHRASE")
991
+ .filter(|phrase| phrase.substructures().next().is_none())
992
+ .map(Node::logical_value)
993
+ .filter(|phrase| !phrase.contains('\n') && !phrase.trim().is_empty());
994
+ if role.first("PHRASE").is_some() && phrase.is_none() {
995
+ pass.note(
996
+ "ROLE",
997
+ ConversionKind::Kept,
998
+ "5.5.1 holds a role on one line with nothing beneath it, and this role's PHRASE spans lines or has substructures; it is kept as written, which 5.5.1 does not define",
999
+ );
1000
+ return false;
1001
+ }
1002
+ if continuation_has_children(role) {
1003
+ refuse_for_continuation_children("ROLE", "role", pass);
1004
+ return false;
1005
+ }
1006
+ let words = phrase.unwrap_or_else(|| role_label(&enumerated));
1007
+ if association {
1008
+ remove_child(role, "PHRASE");
1009
+ role.set_tag("RELA");
1010
+ role.set_logical_value(words.trim());
1011
+ pass.note(
1012
+ "ROLE",
1013
+ ConversionKind::Converted,
1014
+ "an association's role is RELA in 5.5.1, a relation in words",
1015
+ );
1016
+ return true;
1017
+ }
1018
+ if role.first("PHRASE").is_none() && ROLES_5.contains(&enumerated.as_str()) {
1019
+ return false;
1020
+ }
1021
+ remove_child(role, "PHRASE");
1022
+ role.set_logical_value(&format!("({})", words.trim()));
1023
+ pass.note(
1024
+ "ROLE",
1025
+ ConversionKind::Converted,
1026
+ "a role 5.5.1 does not enumerate, or one with a phrase, is 5.5.1's parenthesized role descriptor",
1027
+ );
1028
+ true
1029
+ }
1030
+
1031
+ /// An enumerated role in words, for a 5.5.1 descriptor that has no phrase to
1032
+ /// take them from.
1033
+ fn role_label(enumerated: &str) -> String {
1034
+ match enumerated {
1035
+ "CHIL" => "Child",
1036
+ "CLERGY" => "Clergy",
1037
+ "FATH" => "Father",
1038
+ "FRIEND" => "Friend",
1039
+ "GODP" => "Godparent",
1040
+ "HUSB" => "Husband",
1041
+ "MOTH" => "Mother",
1042
+ "MULTIPLE" => "Multiple",
1043
+ "NGHBR" => "Neighbor",
1044
+ "OFFICIATOR" => "Officiator",
1045
+ "PARENT" => "Parent",
1046
+ "SPOU" => "Spouse",
1047
+ "WIFE" => "Wife",
1048
+ "WITN" => "Witness",
1049
+ "OTHER" => "Other",
1050
+ other => return other.to_owned(),
1051
+ }
1052
+ .to_owned()
1053
+ }
1054
+
848
1055
  /// Converts a document to GEDCOM 5.5.1.
849
1056
  #[must_use]
850
1057
  pub fn to_version_5_5_1(document: &Document) -> Conversion {
@@ -1031,6 +1238,18 @@ fn structures_to_5_node(node: &mut Node, level: usize, pass: &mut Pass) -> bool
1031
1238
  }
1032
1239
  }
1033
1240
 
1241
+ // Version 7's enumerated role goes back to 5.5.1's spelling: free text
1242
+ // in parentheses under a cited event, and `RELA` under an association.
1243
+ if node.tag == "EVEN" || node.tag == "ASSO" {
1244
+ let association = node.tag == "ASSO";
1245
+ for child in &mut node.children {
1246
+ if child.tag == "ROLE" && role_to_5(child, association, pass) {
1247
+ changed = true;
1248
+ crate::build::fit_own_lines(child, level + 1, crate::build::MAX_LINE_LENGTH);
1249
+ }
1250
+ }
1251
+ }
1252
+
1034
1253
  // Everything else version 7 added and 5.5.1 does not define is kept and
1035
1254
  // named. Deleting a translation or a creation date to satisfy a version
1036
1255
  // number would be the worst trade this application could make.
@@ -1312,6 +1531,65 @@ mod tests {
1312
1531
  assert!(note.detail.contains("ole"), "{}", note.detail);
1313
1532
  }
1314
1533
 
1534
+ #[test]
1535
+ fn a_cited_role_in_words_becomes_an_enumerated_role_with_its_phrase() {
1536
+ let document = parse(
1537
+ "0 HEAD\n1 GEDC\n2 VERS 5.5.1\n0 @I1@ INDI\n1 CENS\n2 SOUR @S1@\n3 EVEN CENS\n4 ROLE (Son)\n0 @I2@ INDI\n1 CENS\n2 SOUR @S1@\n3 EVEN CENS\n4 ROLE (Lodger)\n0 @I3@ INDI\n1 BIRT\n2 SOUR @S1@\n3 EVEN BIRT\n4 ROLE MOTH\n0 @S1@ SOUR\n0 TRLR\n",
1538
+ );
1539
+ let outcome = to_version_7(&document);
1540
+ let text = outcome.document.to_text();
1541
+ assert!(text.contains("4 ROLE CHIL\n5 PHRASE Son\n"), "{text}");
1542
+ assert!(text.contains("4 ROLE OTHER\n5 PHRASE Lodger\n"), "{text}");
1543
+ assert!(
1544
+ text.contains("4 ROLE MOTH\n0 @S1@"),
1545
+ "an enumerated role is untouched: {text}"
1546
+ );
1547
+ assert_eq!(
1548
+ outcome
1549
+ .notes
1550
+ .iter()
1551
+ .filter(|note| note.tag == "ROLE")
1552
+ .count(),
1553
+ 2,
1554
+ "{:?}",
1555
+ outcome.notes
1556
+ );
1557
+
1558
+ // And back: the words return as 5.5.1's descriptor, the enumerated
1559
+ // role 5.5.1 also knows stays as it is.
1560
+ let back = to_version_5_5_1(&outcome.document).document.to_text();
1561
+ assert!(back.contains("4 ROLE (Son)\n"), "{back}");
1562
+ assert!(back.contains("4 ROLE (Lodger)\n"), "{back}");
1563
+ assert!(back.contains("4 ROLE MOTH\n"), "{back}");
1564
+ assert!(!back.contains("PHRASE"), "{back}");
1565
+ }
1566
+
1567
+ #[test]
1568
+ fn an_association_relation_is_a_role_in_version_seven_and_back() {
1569
+ let document = parse(
1570
+ "0 HEAD\n1 GEDC\n2 VERS 5.5.1\n0 @I1@ INDI\n1 ASSO @I2@\n2 RELA Godfather\n0 @I2@ INDI\n0 TRLR\n",
1571
+ );
1572
+ let seven = to_version_7(&document).document;
1573
+ let text = seven.to_text();
1574
+ assert!(
1575
+ text.contains("1 ASSO @I2@\n2 ROLE GODP\n3 PHRASE Godfather\n"),
1576
+ "{text}"
1577
+ );
1578
+
1579
+ let back = to_version_5_5_1(&seven).document.to_text();
1580
+ assert!(back.contains("1 ASSO @I2@\n2 RELA Godfather\n"), "{back}");
1581
+ }
1582
+
1583
+ #[test]
1584
+ fn a_version_seven_role_without_a_phrase_reads_in_words_in_5_5_1() {
1585
+ let document = parse(
1586
+ "0 HEAD\n1 GEDC\n2 VERS 7.0\n0 @I1@ INDI\n1 BAPM\n2 SOUR @S1@\n3 EVEN BAPM\n4 ROLE GODP\n1 ASSO @I2@\n2 ROLE FRIEND\n0 @I2@ INDI\n0 @S1@ SOUR\n0 TRLR\n",
1587
+ );
1588
+ let back = to_version_5_5_1(&document).document.to_text();
1589
+ assert!(back.contains("4 ROLE (Godparent)\n"), "{back}");
1590
+ assert!(back.contains("2 RELA Friend\n"), "{back}");
1591
+ }
1592
+
1315
1593
  #[test]
1316
1594
  fn an_unrecognized_source_medium_becomes_other_with_a_phrase() {
1317
1595
  let document = parse(
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes