gedcomkit 0.1.18__tar.gz → 0.1.19__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.18 → gedcomkit-0.1.19}/CHANGELOG.md +85 -0
  2. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/Cargo.lock +1 -1
  3. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/Cargo.toml +1 -1
  4. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/PKG-INFO +1 -1
  5. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/bindings/gedcomkit-py/Cargo.lock +2 -2
  6. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/bindings/gedcomkit-py/Cargo.toml +1 -1
  7. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/bindings/gedcomkit-py/gedcomkit.pyi +27 -2
  8. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/bindings/gedcomkit-py/src/lib.rs +3 -1
  9. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/src/convert.rs +412 -7
  10. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/src/dates.rs +440 -70
  11. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/src/validate.rs +160 -122
  12. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/src/view.rs +42 -1
  13. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/tests/typescript.rs +1 -0
  14. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/LICENSE +0 -0
  15. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/README.md +0 -0
  16. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/bindings/gedcomkit-py/LICENSE +0 -0
  17. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/bindings/gedcomkit-py/README.md +0 -0
  18. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom-spec/LICENSE +0 -0
  19. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom-spec/README.md +0 -0
  20. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom-spec/cardinalities.tsv +0 -0
  21. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom-spec/enumerations.tsv +0 -0
  22. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom-spec/enumerationsets.tsv +0 -0
  23. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom-spec/grammar.abnf +0 -0
  24. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom-spec/grammar.gedstruct +0 -0
  25. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom-spec/payloads.tsv +0 -0
  26. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom-spec/substructures.tsv +0 -0
  27. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/age-invalid.ged +0 -0
  28. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/age-valid.ged +0 -0
  29. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/atsign.ged +0 -0
  30. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/char_ascii_1.ged +0 -0
  31. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/char_ascii_2.ged +0 -0
  32. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/char_utf16be-1.ged +0 -0
  33. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/char_utf16be-2.ged +0 -0
  34. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/char_utf16le-1.ged +0 -0
  35. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/char_utf16le-2.ged +0 -0
  36. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/char_utf8-1.ged +0 -0
  37. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/char_utf8-2.ged +0 -0
  38. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/char_utf8-3.ged +0 -0
  39. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/date-all.ged +0 -0
  40. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/date-dual-invalid.ged +0 -0
  41. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/date-dual-valid.ged +0 -0
  42. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/enum-ext.ged +0 -0
  43. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/filename-1.ged +0 -0
  44. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/lang-all.ged +0 -0
  45. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/notes-1.ged +0 -0
  46. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/obje-1.ged +0 -0
  47. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/obsolete-1.ged +0 -0
  48. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/pedi-1.ged +0 -0
  49. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/rela_1.ged +0 -0
  50. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/sour-1.ged +0 -0
  51. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/tiny-1.ged +0 -0
  52. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/5/xref-case.ged +0 -0
  53. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/age-invalid.ged +0 -0
  54. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/age-valid.ged +0 -0
  55. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/atsign.ged +0 -0
  56. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/char_ascii_1.ged +0 -0
  57. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/char_ascii_2.ged +0 -0
  58. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/char_utf16be-1.ged +0 -0
  59. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/char_utf16be-2.ged +0 -0
  60. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/char_utf16le-1.ged +0 -0
  61. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/char_utf16le-2.ged +0 -0
  62. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/char_utf8-1.ged +0 -0
  63. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/char_utf8-2.ged +0 -0
  64. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/char_utf8-3.ged +0 -0
  65. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/date-all.ged +0 -0
  66. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/date-dual-invalid.ged +0 -0
  67. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/enum-ext.ged +0 -0
  68. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/filename-1.ged +0 -0
  69. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/lang-all.ged +0 -0
  70. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/notes-1.ged +0 -0
  71. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/obje-1.ged +0 -0
  72. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/obsolete-1.ged +0 -0
  73. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/pedi-1.ged +0 -0
  74. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/rela_1.ged +0 -0
  75. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/sour-1.ged +0 -0
  76. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/tiny-1.ged +0 -0
  77. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/7/xref-case.ged +0 -0
  78. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/LICENSE +0 -0
  79. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/gedcom7code/UPSTREAM-README.md +0 -0
  80. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/zip-interop/README.md +0 -0
  81. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/zip-interop/python-zip64.gdz +0 -0
  82. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/fixtures/vendored/zip-interop/python-zipfile.gdz +0 -0
  83. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/pyproject.toml +0 -0
  84. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/src/build.rs +0 -0
  85. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/src/decode.rs +0 -0
  86. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/src/family.rs +0 -0
  87. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/src/gedzip.rs +0 -0
  88. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/src/lib.rs +0 -0
  89. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/src/names.rs +0 -0
  90. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/src/schema.rs +0 -0
  91. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/src/synthetic.rs +0 -0
  92. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/src/tags.rs +0 -0
  93. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/tests/endurance.rs +0 -0
  94. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/tests/external_charset.rs +0 -0
  95. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/tests/properties.rs +0 -0
  96. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/tests/requirement_corpus.rs +0 -0
  97. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/tests/spec_conformance.rs +0 -0
  98. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/tests/vendored_corpus.rs +0 -0
  99. {gedcomkit-0.1.18 → gedcomkit-0.1.19}/tests/version_conversion.rs +0 -0
@@ -7,6 +7,91 @@ rules.
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.19] - 2026-10-17
11
+
12
+ ### Added
13
+
14
+ - `view::EventView::date_phrase` (`datePhrase` in the serialized view, so
15
+ in both bindings and the TypeScript definitions): the `PHRASE` beneath an
16
+ event's `DATE`, exactly as written. A GEDCOM 7 date with an empty payload
17
+ and a `PHRASE`, or a date with a phrase beside it, showed nothing of the
18
+ phrase before. A 5.5.1 phrase is written inside the payload, which `date`
19
+ already holds. The Python stub gains an `EventView` typed dict naming
20
+ every key of an event.
21
+ - The date reader reads ISO 8601's year and month: `1834-02` is February
22
+ 1834, as `1834-02-10` was already 10 February 1834. Only four digits, a
23
+ hyphen and two digits are read, and only when the month is no more than
24
+ the year's last two digits: `1808-10`, `1711-12` and `2010-11` are as
25
+ often a span of years or a dual year as a month, and stay unparsed. So
26
+ does every month of a year ending in 00 (`1800-01` to `1800-12`), and
27
+ the later months of a year ending in 01 to 11 (`2005-06` to `2005-12`).
28
+
29
+ ### Changed
30
+
31
+ - `validate::inspect` no longer reports two events out of order when an
32
+ approximate date's leeway could put them in order, which is how
33
+ `conflicting-vitals` already read them. A birth `ABT 1854` with a
34
+ christening in 1853 was a `christened-before-born` question; it is now
35
+ no finding. The same holds for `died-before-born`, `buried-before-died`,
36
+ `event-after-death`, `event-before-birth`, `married-before-born`,
37
+ `married-after-died` and `child-born-after-mother-died`, each of which
38
+ now reports only what is out of order beyond the leeway, as a
39
+ contradiction (a question for an `EVEN` or `FACT`, as before). Within the
40
+ leeway no other check fires in its place: a marriage in 1849 for somebody
41
+ born about 1850 is not `married-as-a-child`. Bounds (`BEF`, `AFT`, `BET`)
42
+ are read as the ranges they admit, unchanged.
43
+ - `convert::to_version_5_5_1` turns a name type back into 5.5.1's
44
+ spelling: `OTHER` with a one-line `PHRASE` becomes the phrase's words,
45
+ and an enumerated value is written in lower case (`BIRTH` becomes
46
+ `birth`). `OTHER` whose words are themselves an enumerated value (the
47
+ phrase `birth`) stays `other`, its `PHRASE` kept and reported, since the
48
+ words alone would convert back up as `BIRTH`. A `PHRASE` beside any other
49
+ value is kept and reported.
50
+ - `DatePoint::from_ymd` refuses a day its month never has (30 February,
51
+ 31 April, day 0). The point is written with no calendar escape, which
52
+ makes it Gregorian, so 29 February is taken only in a Gregorian leap
53
+ year: 2000, not 1900.
54
+
55
+ ### Fixed
56
+
57
+ - A range with an end the date reader cannot read is no longer read in
58
+ part. `FROM ABT 1924 TO 1959` was read as `TO 1959`, dropping the start
59
+ without a word, and `BET 1950 AND JULIAN DEC 302` as a range with only
60
+ its first end; `FROM Infant` was a period with neither end. Each is now
61
+ `DateKind::Unparsed`, keeps its text as the phrase, and still offers its
62
+ year to sort by; `to_version_7` keeps it whole as an empty `DATE` with a
63
+ `PHRASE`. A range whose ends are both dates reads as before.
64
+ - The date reader no longer accepts a day its month does not have.
65
+ `1834-02-30` and `30 FEB 1834` were read as 30 February 1834; they are
66
+ now unparsed, as is `29 FEB 1834`. Leap years follow the payload's
67
+ calendar: `@#DJULIAN@ 29 FEB 1900` is read and `@#DGREGORIAN@ 29 FEB
68
+ 1900` is not. A payload with no calendar escape keeps the Julian leap
69
+ day, since files often leave the escape off a Julian date, so
70
+ `29 FEB 1900` is still read; ISO 8601's `1900-02-29` is not, since ISO
71
+ 8601 is Gregorian by definition. A dual year's leap day is its second
72
+ year's, so `29 FEB 1711/12` and `@#DJULIAN@ 29 FEB 1711/12` are read. A
73
+ Roman or unrecognized calendar is held to the Julian month lengths, and
74
+ no Hebrew or French Republican month has more than 30 days. A numeric
75
+ date with a month or day out of range (`1834-13-05`, `13/05/1834`) is
76
+ unparsed too; before, the piece out of range was dropped and the rest
77
+ read (`1834-13-05` as the year 1834).
78
+ - `convert::to_version_5_5_1` no longer writes `INT (…)`, an interpreted
79
+ date with no date, for a version 7 `DATE` that is empty beside its
80
+ `PHRASE`; 5.5.1 does not allow it. The phrase is written as 5.5.1's date
81
+ phrase, `(FROM ABT 1924 TO 1959)`, or as the date itself when it is
82
+ already one in 5.5.1's grammar that version 7 cannot spell, so a dual
83
+ year (`1740/41`) comes back as the date it was.
84
+ - `convert::to_version_7` / `to_version_7_with` convert a personal name's
85
+ type (`INDI`.`NAME`.`TYPE`), which 5.5.1 writes as free text and version
86
+ 7 as an enumeration. A value that is one of version 7's words in any case
87
+ (`aka`, `birth`, `immigrant`, `maiden`, `married`, `professional`,
88
+ `other`) becomes that word in upper case; any other becomes `OTHER`, with
89
+ the text kept verbatim as a `PHRASE`, the way a role has been converted
90
+ since 0.1.13. Before, `TYPE aka` was carried into the 7.0 document as it
91
+ stood, where it is not valid. A type that already has a `PHRASE`, is
92
+ empty, or has a continuation line with substructures is kept and
93
+ reported.
94
+
10
95
  ## [0.1.18] - 2026-10-16
11
96
 
12
97
  ### Added
@@ -35,7 +35,7 @@ dependencies = [
35
35
 
36
36
  [[package]]
37
37
  name = "gedcomkit"
38
- version = "0.1.18"
38
+ version = "0.1.19"
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.18"
6
+ version = "0.1.19"
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.18
3
+ Version: 0.1.19
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.18"
38
+ version = "0.1.19"
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.18"
46
+ version = "0.1.19"
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.18"
5
+ version = "0.1.19"
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"
@@ -19,6 +19,29 @@ class RecordSpec(TypedDict):
19
19
  value: NotRequired[str | None]
20
20
  children: NotRequired[list[RecordSpec]]
21
21
 
22
+ class EventView(TypedDict):
23
+ """One entry of an individual's or a family's ``events``.
24
+
25
+ ``date`` is the DATE payload exactly as written. ``datePhrase`` is the
26
+ GEDCOM 7 PHRASE beneath that DATE, or ``None``: beside a date, or the
27
+ only text an empty 7.0 DATE has. A 5.5.1 phrase is part of ``date``.
28
+ """
29
+
30
+ tag: str
31
+ label: str
32
+ date: str | None
33
+ datePhrase: str | None
34
+ dateDisplay: str | None
35
+ year: int | None
36
+ sortKey: int | None
37
+ place: str | None
38
+ age: str | None
39
+ cause: str | None
40
+ attribute: bool
41
+ value: str | None
42
+ citations: list[str]
43
+ path: list[int]
44
+
22
45
  class WrappedLine(TypedDict):
23
46
  record: str
24
47
  path: list[int]
@@ -42,8 +65,10 @@ class Gedcom:
42
65
  def content_records(self) -> int: ...
43
66
  def records(self) -> list[dict[str, Any]]: ...
44
67
  def record(self, xref: str) -> dict[str, Any]: ...
45
- def individuals(self) -> list[dict[str, Any]]: ...
46
- def families(self) -> list[dict[str, Any]]: ...
68
+ def individuals(self) -> list[dict[str, Any]]:
69
+ """Every individual's view; each of its ``events`` is an ``EventView``."""
70
+ def families(self) -> list[dict[str, Any]]:
71
+ """Every family's view; each of its ``events`` is an ``EventView``."""
47
72
  def validate(self) -> list[dict[str, Any]]:
48
73
  """Plausibility findings, each with ``record``, ``check``, ``severity``
49
74
  and ``message``. ``check`` is the core's stable rule name, such as
@@ -224,6 +224,8 @@ impl Gedcom {
224
224
  }
225
225
 
226
226
  /// Every individual, read for display: names, events, families, IDs.
227
+ /// Each event has its `date` payload as written and, beside it,
228
+ /// `datePhrase`: the GEDCOM 7 `PHRASE` under the date, or `None`.
227
229
  fn individuals(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
228
230
  self.document
229
231
  .records_of("INDI")
@@ -231,7 +233,7 @@ impl Gedcom {
231
233
  .collect()
232
234
  }
233
235
 
234
- /// Every family record's view.
236
+ /// Every family record's view, its events shaped as `individuals`' are.
235
237
  fn families(&self, py: Python<'_>) -> PyResult<Vec<Py<PyAny>>> {
236
238
  self.document
237
239
  .records_of("FAM")
@@ -336,6 +336,16 @@ fn structures_to_7_node(node: &mut Node, shared: &[String], pass: &mut Pass) ->
336
336
  }
337
337
  }
338
338
 
339
+ // A personal name's type, free text in 5.5.1, is version 7's enumerated
340
+ // name type, the words as written kept in a `PHRASE` — as a role is.
341
+ if node.tag == "INDI" {
342
+ for name in node.children.iter_mut().filter(|child| child.tag == "NAME") {
343
+ for kind in name.children.iter_mut().filter(|child| child.tag == "TYPE") {
344
+ changed |= name_type_to_7(kind, pass);
345
+ }
346
+ }
347
+ }
348
+
339
349
  // A multimedia record's files: `FORM` moves from the object to each
340
350
  // `FILE` (5.5), and each file's path, format and medium take version 7's
341
351
  // shape (5.5.1 already nests `FORM` under `FILE`, with `TYPE` beneath).
@@ -977,6 +987,148 @@ fn role_to_7(role: &mut Node, pass: &mut Pass) -> bool {
977
987
  true
978
988
  }
979
989
 
990
+ /// Version 7's enumerated name types (`enumset-NAME-TYPE`).
991
+ const NAME_TYPES_7: &[&str] = &[
992
+ "AKA",
993
+ "BIRTH",
994
+ "IMMIGRANT",
995
+ "MAIDEN",
996
+ "MARRIED",
997
+ "PROFESSIONAL",
998
+ "OTHER",
999
+ ];
1000
+
1001
+ /// A personal name's `TYPE` (`INDI`.`NAME`.`TYPE`) in version 7's shape.
1002
+ /// 5.5.1's values are free text, its own five (`aka`, `birth`, `immigrant`,
1003
+ /// `maiden`, `married`) written in lower case; version 7's are an upper-case
1004
+ /// enumeration. A value that is one of version 7's words in any case
1005
+ /// becomes that word; any other becomes `OTHER`, with the text kept verbatim
1006
+ /// as a `PHRASE`. A type that already has a `PHRASE`, is empty, or has a
1007
+ /// continuation line with substructures is kept and reported.
1008
+ fn name_type_to_7(kind: &mut Node, pass: &mut Pass) -> bool {
1009
+ const LABEL: &str = "NAME.TYPE";
1010
+ let original = kind.logical_value();
1011
+ let trimmed = original.trim();
1012
+ let upper = trimmed.to_ascii_uppercase();
1013
+ if NAME_TYPES_7.contains(&upper.as_str()) && upper == original {
1014
+ return false;
1015
+ }
1016
+ if continuation_has_children(kind) {
1017
+ refuse_for_continuation_children(LABEL, "name type", pass);
1018
+ return false;
1019
+ }
1020
+ if NAME_TYPES_7.contains(&upper.as_str()) {
1021
+ kind.set_logical_value(&upper);
1022
+ pass.note(
1023
+ LABEL,
1024
+ ConversionKind::Converted,
1025
+ format!("the name type {original:?} is version 7's enumerated value {upper}"),
1026
+ );
1027
+ return true;
1028
+ }
1029
+ if trimmed.is_empty() {
1030
+ pass.note(
1031
+ LABEL,
1032
+ ConversionKind::Kept,
1033
+ "an empty name type has no version 7 reading; it is left as it is",
1034
+ );
1035
+ return false;
1036
+ }
1037
+ if kind.first("PHRASE").is_some() {
1038
+ pass.note(
1039
+ LABEL,
1040
+ ConversionKind::Kept,
1041
+ format!(
1042
+ "the name type {original:?} is not version 7's enumeration, but it already has a PHRASE to keep the words in; it is left as it is"
1043
+ ),
1044
+ );
1045
+ return false;
1046
+ }
1047
+ kind.set_logical_value("OTHER");
1048
+ // The text moves as written, as a role's does: a leading `@` that 5.5.1
1049
+ // escaped as `@@` is version 7's escape too.
1050
+ kind.push(Node::with_value("PHRASE", trimmed));
1051
+ pass.note(
1052
+ LABEL,
1053
+ ConversionKind::Converted,
1054
+ format!(
1055
+ "the name type {original:?} is none of version 7's enumerated values; it became OTHER, with the words kept as a PHRASE"
1056
+ ),
1057
+ );
1058
+ true
1059
+ }
1060
+
1061
+ /// A version 7 name type in 5.5.1's spelling. `OTHER` with a one-line
1062
+ /// `PHRASE` becomes the phrase's words, which is how 5.5.1 holds a type it
1063
+ /// does not list — unless the words are themselves one of the enumerated
1064
+ /// values (`OTHER` with the phrase `birth`), which would come back as that
1065
+ /// value, not as `OTHER`. Any other enumerated value, and `OTHER` then, is
1066
+ /// written in lower case, as 5.5.1 writes its own five; a `PHRASE` beside
1067
+ /// one of those, or one that spans lines or has substructures, has no place
1068
+ /// in 5.5.1 and is kept as written and reported.
1069
+ fn name_type_to_5(kind: &mut Node, level: usize, pass: &mut Pass) -> bool {
1070
+ const LABEL: &str = "NAME.TYPE";
1071
+ let original = kind.logical_value();
1072
+ let enumerated = original.trim().to_ascii_uppercase();
1073
+ if !NAME_TYPES_7.contains(&enumerated.as_str()) {
1074
+ return false;
1075
+ }
1076
+ if continuation_has_children(kind) {
1077
+ refuse_for_continuation_children(LABEL, "name type", pass);
1078
+ return false;
1079
+ }
1080
+ let phrase = kind
1081
+ .first("PHRASE")
1082
+ .filter(|phrase| phrase.substructures().next().is_none())
1083
+ .map(Node::logical_value)
1084
+ .filter(|phrase| !phrase.contains('\n') && !phrase.trim().is_empty());
1085
+ let reads_as_enumerated = phrase
1086
+ .as_deref()
1087
+ .is_some_and(|words| NAME_TYPES_7.contains(&words.trim().to_ascii_uppercase().as_str()));
1088
+ let changed = match phrase {
1089
+ Some(words)
1090
+ if enumerated == "OTHER" && kind.all("PHRASE").count() == 1 && !reads_as_enumerated =>
1091
+ {
1092
+ remove_child(kind, "PHRASE");
1093
+ kind.set_logical_value(words.trim());
1094
+ pass.note(
1095
+ LABEL,
1096
+ ConversionKind::Converted,
1097
+ "version 7's OTHER name type with a PHRASE is 5.5.1's name type in words",
1098
+ );
1099
+ true
1100
+ }
1101
+ _ => {
1102
+ let lower = enumerated.to_ascii_lowercase();
1103
+ let changed = lower != original;
1104
+ if changed {
1105
+ kind.set_logical_value(&lower);
1106
+ pass.note(
1107
+ LABEL,
1108
+ ConversionKind::Converted,
1109
+ format!("the name type {original:?} is {lower:?} in 5.5.1's spelling"),
1110
+ );
1111
+ }
1112
+ if kind.first("PHRASE").is_some() {
1113
+ pass.note(
1114
+ "PHRASE",
1115
+ ConversionKind::Kept,
1116
+ if enumerated == "OTHER" && reads_as_enumerated {
1117
+ "5.5.1 has no phrase beside a name type, and this OTHER type's words are themselves an enumerated value, which they would be read as; the PHRASE is kept as written, which 5.5.1 does not define"
1118
+ } else {
1119
+ "5.5.1 has no phrase beside a name type; the PHRASE is kept as written, which 5.5.1 does not define"
1120
+ },
1121
+ );
1122
+ }
1123
+ changed
1124
+ }
1125
+ };
1126
+ if changed {
1127
+ crate::build::fit_own_lines(kind, level, crate::build::MAX_LINE_LENGTH);
1128
+ }
1129
+ changed
1130
+ }
1131
+
980
1132
  /// The roles 5.5.1 enumerates under a cited event.
981
1133
  const ROLES_5: &[&str] = &["CHIL", "HUSB", "WIFE", "MOTH", "FATH", "SPOU"];
982
1134
 
@@ -1204,7 +1356,10 @@ fn structures_to_5_node(node: &mut Node, level: usize, pass: &mut Pass) -> bool
1204
1356
  }
1205
1357
 
1206
1358
  // `DATE` with a `PHRASE` folds back into 5.5.1's interpreted form, which
1207
- // is the same information in the older spelling.
1359
+ // is the same information in the older spelling. An empty date has no
1360
+ // `INT` form, which needs a date: its phrase is 5.5.1's date phrase
1361
+ // `(…)`, or the date itself when the words are already a date in 5.5.1's
1362
+ // grammar that version 7 cannot spell, such as a dual year.
1208
1363
  if node.tag == "DATE"
1209
1364
  && let Some(phrase_node) = node.first("PHRASE")
1210
1365
  {
@@ -1226,15 +1381,28 @@ fn structures_to_5_node(node: &mut Node, level: usize, pass: &mut Pass) -> bool
1226
1381
  "5.5.1's interpreted date holds one line and nothing beneath it, and this date or phrase spans lines or has substructures; the PHRASE is kept as written, which 5.5.1 does not define",
1227
1382
  );
1228
1383
  } else {
1384
+ let words = phrase.trim();
1385
+ let (written, detail) = if !payload.trim().is_empty() {
1386
+ (
1387
+ format!("INT {payload} ({phrase})"),
1388
+ "a dated phrase became 5.5.1's interpreted form",
1389
+ )
1390
+ } else if GedcomDate::parse(words).to_gedcom_5_5_1().as_deref() == Some(words) {
1391
+ (
1392
+ words.to_owned(),
1393
+ "a phrase with no date beside it is a date in 5.5.1's own grammar, which version 7 cannot spell; it became the date",
1394
+ )
1395
+ } else {
1396
+ (
1397
+ format!("({words})"),
1398
+ "a phrase with no date beside it became 5.5.1's date phrase",
1399
+ )
1400
+ };
1229
1401
  remove_child(node, "PHRASE");
1230
- node.set_logical_value(&format!("INT {payload} ({phrase})"));
1402
+ node.set_logical_value(&written);
1231
1403
  changed = true;
1232
1404
  own = true;
1233
- pass.note(
1234
- "PHRASE",
1235
- ConversionKind::Converted,
1236
- "a dated phrase became 5.5.1's interpreted form",
1237
- );
1405
+ pass.note("PHRASE", ConversionKind::Converted, detail);
1238
1406
  }
1239
1407
  }
1240
1408
 
@@ -1250,6 +1418,15 @@ fn structures_to_5_node(node: &mut Node, level: usize, pass: &mut Pass) -> bool
1250
1418
  }
1251
1419
  }
1252
1420
 
1421
+ // Version 7's enumerated name type goes back to 5.5.1's free text.
1422
+ if node.tag == "INDI" {
1423
+ for name in node.children.iter_mut().filter(|child| child.tag == "NAME") {
1424
+ for kind in name.children.iter_mut().filter(|child| child.tag == "TYPE") {
1425
+ changed |= name_type_to_5(kind, level + 2, pass);
1426
+ }
1427
+ }
1428
+ }
1429
+
1253
1430
  // Everything else version 7 added and 5.5.1 does not define is kept and
1254
1431
  // named. Deleting a translation or a creation date to satisfy a version
1255
1432
  // number would be the worst trade this application could make.
@@ -1478,6 +1655,8 @@ mod tests {
1478
1655
  ("ABT Oct 1803", "ABT OCT 1803"),
1479
1656
  ("44 B.C.", "44 BCE"),
1480
1657
  ("@#DJULIAN@ 10 MAR 1700", "JULIAN 10 MAR 1700"),
1658
+ ("1834-02", "FEB 1834"),
1659
+ ("1834-02-10", "10 FEB 1834"),
1481
1660
  ];
1482
1661
  for (original, rendered) in cases {
1483
1662
  let document = parse(&format!(
@@ -1504,6 +1683,232 @@ mod tests {
1504
1683
  }
1505
1684
  }
1506
1685
 
1686
+ #[test]
1687
+ fn a_date_the_reader_once_read_in_part_is_kept_whole_as_a_phrase() {
1688
+ // `FROM ABT 1924 TO 1959` was written as `TO 1959`, and `30 FEB 1834`
1689
+ // passed through as a date: neither is version 7's grammar, and the
1690
+ // first lost its start.
1691
+ for original in [
1692
+ "FROM ABT 1924 TO 1959",
1693
+ "BET ABT 1900 AND 1910",
1694
+ "30 FEB 1834",
1695
+ "1834-02-30",
1696
+ ] {
1697
+ let document = parse(&format!(
1698
+ "0 HEAD\n1 GEDC\n2 VERS 5.5.1\n0 @I1@ INDI\n1 RESI\n2 DATE {original}\n0 TRLR\n"
1699
+ ));
1700
+ let outcome = to_version_7(&document);
1701
+ let text = outcome.document.to_text();
1702
+ assert!(
1703
+ text.contains(&format!("2 DATE\n3 PHRASE {original}\n")),
1704
+ "{original:?} -> {text}"
1705
+ );
1706
+ let note = outcome
1707
+ .notes
1708
+ .iter()
1709
+ .find(|note| note.tag == "DATE")
1710
+ .unwrap_or_else(|| panic!("a note about {original:?}"));
1711
+ assert!(note.detail.contains("cannot hold"), "{}", note.detail);
1712
+ }
1713
+ }
1714
+
1715
+ #[test]
1716
+ fn a_name_type_in_words_becomes_version_7s_enumeration_with_its_phrase() {
1717
+ let document = parse(
1718
+ "0 HEAD\n1 GEDC\n2 VERS 5.5.1\n0 @I1@ INDI\n\
1719
+ 1 NAME Wren /Testwright/\n2 TYPE aka\n\
1720
+ 1 NAME Wren /Elder/\n2 TYPE Birth\n\
1721
+ 1 NAME Wren /Stage/\n2 TYPE professional\n\
1722
+ 1 NAME Wren /Quill/\n2 TYPE MARRIED\n\
1723
+ 1 NAME Wren /Nick/\n2 TYPE Nickname\n\
1724
+ 1 NAME Wren /Shelf/\n2 TYPE also known as\n\
1725
+ 0 @R1@ REPO\n1 NAME Example Archive\n2 TYPE county office\n0 TRLR\n",
1726
+ );
1727
+ let outcome = to_version_7(&document);
1728
+ let text = outcome.document.to_text();
1729
+ for expected in [
1730
+ "1 NAME Wren /Testwright/\n2 TYPE AKA\n1 NAME",
1731
+ "1 NAME Wren /Elder/\n2 TYPE BIRTH\n1 NAME",
1732
+ "1 NAME Wren /Stage/\n2 TYPE PROFESSIONAL\n1 NAME",
1733
+ "1 NAME Wren /Quill/\n2 TYPE MARRIED\n1 NAME",
1734
+ "1 NAME Wren /Nick/\n2 TYPE OTHER\n3 PHRASE Nickname\n1 NAME",
1735
+ "1 NAME Wren /Shelf/\n2 TYPE OTHER\n3 PHRASE also known as\n0 @R1@",
1736
+ // Only a person's name has a type in either version.
1737
+ "1 NAME Example Archive\n2 TYPE county office\n",
1738
+ ] {
1739
+ assert!(text.contains(expected), "{expected:?} in {text}");
1740
+ }
1741
+ let notes: Vec<&ConversionNote> = outcome
1742
+ .notes
1743
+ .iter()
1744
+ .filter(|note| note.tag == "NAME.TYPE")
1745
+ .collect();
1746
+ assert_eq!(
1747
+ notes.len(),
1748
+ 5,
1749
+ "an enumerated value is untouched: {notes:?}"
1750
+ );
1751
+ assert!(
1752
+ notes
1753
+ .iter()
1754
+ .all(|note| note.kind == ConversionKind::Converted)
1755
+ );
1756
+ assert!(
1757
+ notes
1758
+ .iter()
1759
+ .any(|note| note.detail.contains("\"Nickname\"")),
1760
+ "{notes:?}"
1761
+ );
1762
+ // A second conversion has nothing left to do.
1763
+ assert_eq!(to_version_7(&outcome.document).document.to_text(), text);
1764
+
1765
+ // And back: the words return as 5.5.1's free text, and the
1766
+ // enumerated values in 5.5.1's lower case.
1767
+ let back = to_version_5_5_1(&outcome.document);
1768
+ let back_text = back.document.to_text();
1769
+ for expected in [
1770
+ "2 TYPE aka\n",
1771
+ "2 TYPE birth\n",
1772
+ "2 TYPE professional\n",
1773
+ "2 TYPE married\n",
1774
+ "2 TYPE Nickname\n",
1775
+ "2 TYPE also known as\n",
1776
+ "2 TYPE county office\n",
1777
+ ] {
1778
+ assert!(back_text.contains(expected), "{expected:?} in {back_text}");
1779
+ }
1780
+ assert!(!back_text.contains("PHRASE"), "{back_text}");
1781
+ assert!(
1782
+ back.notes
1783
+ .iter()
1784
+ .all(|note| note.kind != ConversionKind::Kept),
1785
+ "{:?}",
1786
+ back.notes
1787
+ );
1788
+ }
1789
+
1790
+ #[test]
1791
+ fn a_name_type_that_cannot_be_converted_is_kept_and_reported() {
1792
+ let document = parse(
1793
+ "0 HEAD\n1 GEDC\n2 VERS 5.5.1\n0 @I1@ INDI\n\
1794
+ 1 NAME Wren /Testwright/\n2 TYPE Nickname\n3 PHRASE already here\n\
1795
+ 1 NAME Wren /Blank/\n2 TYPE\n0 TRLR\n",
1796
+ );
1797
+ let outcome = to_version_7(&document);
1798
+ let text = outcome.document.to_text();
1799
+ assert!(
1800
+ text.contains("2 TYPE Nickname\n3 PHRASE already here\n"),
1801
+ "{text}"
1802
+ );
1803
+ assert!(text.contains("1 NAME Wren /Blank/\n2 TYPE\n"), "{text}");
1804
+ let kept: Vec<&ConversionNote> = outcome
1805
+ .notes
1806
+ .iter()
1807
+ .filter(|note| note.tag == "NAME.TYPE")
1808
+ .collect();
1809
+ assert_eq!(kept.len(), 2, "{kept:?}");
1810
+ assert!(kept.iter().all(|note| note.kind == ConversionKind::Kept));
1811
+
1812
+ // Going down, an enumerated value with a phrase beside it keeps the
1813
+ // phrase rather than losing either.
1814
+ let seven = parse(
1815
+ "0 HEAD\n1 GEDC\n2 VERS 7.0\n0 @I1@ INDI\n1 NAME Wren /Testwright/\n2 TYPE BIRTH\n3 PHRASE name at baptism\n0 TRLR\n",
1816
+ );
1817
+ let back = to_version_5_5_1(&seven);
1818
+ let back_text = back.document.to_text();
1819
+ assert!(
1820
+ back_text.contains("2 TYPE birth\n3 PHRASE name at baptism\n"),
1821
+ "{back_text}"
1822
+ );
1823
+ assert!(
1824
+ back.notes
1825
+ .iter()
1826
+ .any(|note| note.tag == "PHRASE" && note.kind == ConversionKind::Kept),
1827
+ "{:?}",
1828
+ back.notes
1829
+ );
1830
+ }
1831
+
1832
+ #[test]
1833
+ fn a_name_type_round_trips_without_growing_or_changing_its_meaning() {
1834
+ // A leading `@` is escaped as `@@` in both versions, so it moves as
1835
+ // written; it once gained an `@` on every conversion up.
1836
+ let five = parse(
1837
+ "0 HEAD\n1 GEDC\n2 VERS 5.5.1\n0 @I1@ INDI\n1 NAME Wren /Testwright/\n2 TYPE @@home\n0 TRLR\n",
1838
+ );
1839
+ let seven = to_version_7(&five).document;
1840
+ let text = seven.to_text();
1841
+ assert!(text.contains("2 TYPE OTHER\n3 PHRASE @@home\n"), "{text}");
1842
+ let back = to_version_5_5_1(&seven).document;
1843
+ let back_text = back.to_text();
1844
+ assert!(back_text.contains("2 TYPE @@home\n"), "{back_text}");
1845
+ assert_eq!(to_version_7(&back).document.to_text(), text);
1846
+
1847
+ // `OTHER` whose words are an enumerated value would come back up as
1848
+ // that value, so it stays `other` beside its phrase.
1849
+ let seven = parse(
1850
+ "0 HEAD\n1 GEDC\n2 VERS 7.0\n0 @I1@ INDI\n1 NAME Wren /Testwright/\n2 TYPE OTHER\n3 PHRASE birth\n0 TRLR\n",
1851
+ );
1852
+ let down = to_version_5_5_1(&seven);
1853
+ let down_text = down.document.to_text();
1854
+ assert!(
1855
+ down_text.contains("2 TYPE other\n3 PHRASE birth\n"),
1856
+ "{down_text}"
1857
+ );
1858
+ assert!(
1859
+ down.notes
1860
+ .iter()
1861
+ .any(|note| note.tag == "PHRASE" && note.kind == ConversionKind::Kept),
1862
+ "{:?}",
1863
+ down.notes
1864
+ );
1865
+ let up = to_version_7(&down.document).document.to_text();
1866
+ assert!(up.contains("2 TYPE OTHER\n3 PHRASE birth\n"), "{up}");
1867
+ }
1868
+
1869
+ #[test]
1870
+ fn an_empty_date_with_a_phrase_goes_down_as_a_valid_5_5_1_date() {
1871
+ // `INT` needs a date beside its phrase, so an empty one was written
1872
+ // `INT (…)`, which 5.5.1 does not allow.
1873
+ for (original, down) in [
1874
+ ("FROM ABT 1924 TO 1959", "(FROM ABT 1924 TO 1959)"),
1875
+ ("Infant", "(Infant)"),
1876
+ // A dual year is a 5.5.1 date that version 7 cannot spell.
1877
+ ("1740/41", "1740/41"),
1878
+ ("10 JAN 1699/00", "10 JAN 1699/00"),
1879
+ ] {
1880
+ let five = parse(&format!(
1881
+ "0 HEAD\n1 GEDC\n2 VERS 5.5.1\n0 @I1@ INDI\n1 BIRT\n2 DATE {original}\n0 TRLR\n"
1882
+ ));
1883
+ let seven = to_version_7(&five).document;
1884
+ assert!(
1885
+ seven
1886
+ .to_text()
1887
+ .contains(&format!("2 DATE\n3 PHRASE {original}\n")),
1888
+ "{original:?}"
1889
+ );
1890
+ let outcome = to_version_5_5_1(&seven);
1891
+ let text = outcome.document.to_text();
1892
+ assert!(
1893
+ text.contains(&format!("1 BIRT\n2 DATE {down}\n")),
1894
+ "{original:?} -> {text}"
1895
+ );
1896
+ assert!(!text.contains("INT") && !text.contains("PHRASE"), "{text}");
1897
+ let note = outcome
1898
+ .notes
1899
+ .iter()
1900
+ .find(|note| note.tag == "PHRASE")
1901
+ .expect("a note about the phrase");
1902
+ assert_eq!(note.kind, ConversionKind::Converted);
1903
+ }
1904
+ // A version 7 phrase of its own goes down the same way.
1905
+ let seven = parse(
1906
+ "0 HEAD\n1 GEDC\n2 VERS 7.0\n0 @I1@ INDI\n1 BIRT\n2 DATE\n3 PHRASE in his youth\n0 TRLR\n",
1907
+ );
1908
+ let text = to_version_5_5_1(&seven).document.to_text();
1909
+ assert!(text.contains("2 DATE (in his youth)\n"), "{text}");
1910
+ }
1911
+
1507
1912
  #[test]
1508
1913
  fn a_dual_year_has_no_version_7_reading_and_becomes_an_empty_date_with_a_phrase() {
1509
1914
  let document =