Merge pull request 'Fix #169: keep citation links on GEDCOM export' (#232) from fix-gedcom-citation-export into main
build-backend / build (push) Successful in 29s
build-backend / build (push) Successful in 29s
This commit was merged in pull request #232.
This commit is contained in:
@@ -692,10 +692,45 @@ async def export_gedcom(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tr
|
|||||||
await session.execute(select(Place).where(Place.tree_id == tree.id))
|
await session.execute(select(Place).where(Place.tree_id == tree.id))
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
}
|
}
|
||||||
|
citations = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Citation).where(
|
||||||
|
Citation.tree_id == tree.id, Citation.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
|
||||||
pxref = {p.id: f"@I{i + 1}@" for i, p in enumerate(persons)}
|
pxref = {p.id: f"@I{i + 1}@" for i, p in enumerate(persons)}
|
||||||
gender_by_id = {p.id: p.gender for p in persons}
|
gender_by_id = {p.id: p.gender for p in persons}
|
||||||
sxref = {s.id: f"@S{i + 1}@" for i, s in enumerate(sources)}
|
sxref = {s.id: f"@S{i + 1}@" for i, s in enumerate(sources)}
|
||||||
|
# Citations grouped by the fact they sit on, so each fact can emit its SOUR
|
||||||
|
# links (dropping these is the round-trip data loss this fixes). Skip any
|
||||||
|
# whose source didn't export.
|
||||||
|
cite_by_person: dict[uuid.UUID, list[Citation]] = defaultdict(list)
|
||||||
|
cite_by_name: dict[uuid.UUID, list[Citation]] = defaultdict(list)
|
||||||
|
cite_by_event: dict[uuid.UUID, list[Citation]] = defaultdict(list)
|
||||||
|
cite_by_rel: dict[uuid.UUID, list[Citation]] = defaultdict(list)
|
||||||
|
for c in citations:
|
||||||
|
if c.source_id not in sxref:
|
||||||
|
continue
|
||||||
|
if c.person_id:
|
||||||
|
cite_by_person[c.person_id].append(c)
|
||||||
|
elif c.event_id:
|
||||||
|
cite_by_event[c.event_id].append(c)
|
||||||
|
elif c.name_id:
|
||||||
|
cite_by_name[c.name_id].append(c)
|
||||||
|
elif c.relationship_id:
|
||||||
|
cite_by_rel[c.relationship_id].append(c)
|
||||||
|
|
||||||
|
def cite_lines(cites: list[Citation], depth: int) -> list[str]:
|
||||||
|
lines: list[str] = []
|
||||||
|
for c in cites:
|
||||||
|
lines.append(f"{depth} SOUR {sxref[c.source_id]}")
|
||||||
|
if c.page:
|
||||||
|
lines.append(f"{depth + 1} PAGE {c.page}")
|
||||||
|
return lines
|
||||||
names_by_person: dict[uuid.UUID, list[Name]] = defaultdict(list)
|
names_by_person: dict[uuid.UUID, list[Name]] = defaultdict(list)
|
||||||
for n in sorted(names, key=lambda n: (n.sort_order, not n.is_primary)):
|
for n in sorted(names, key=lambda n: (n.sort_order, not n.is_primary)):
|
||||||
names_by_person[n.person_id].append(n)
|
names_by_person[n.person_id].append(n)
|
||||||
@@ -747,6 +782,7 @@ async def export_gedcom(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tr
|
|||||||
ged_type = EXPORT_TYPE_MAP.get(n.name_type)
|
ged_type = EXPORT_TYPE_MAP.get(n.name_type)
|
||||||
if ged_type:
|
if ged_type:
|
||||||
out.append(f"2 TYPE {ged_type}")
|
out.append(f"2 TYPE {ged_type}")
|
||||||
|
out += cite_lines(cite_by_name.get(n.id, []), 2)
|
||||||
sex = {"male": "M", "female": "F"}.get(p.gender or "")
|
sex = {"male": "M", "female": "F"}.get(p.gender or "")
|
||||||
if sex:
|
if sex:
|
||||||
out.append(f"1 SEX {sex}")
|
out.append(f"1 SEX {sex}")
|
||||||
@@ -759,6 +795,8 @@ async def export_gedcom(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tr
|
|||||||
out.append(f"2 DATE {e.date_value}")
|
out.append(f"2 DATE {e.date_value}")
|
||||||
if e.place_id and e.place_id in places:
|
if e.place_id and e.place_id in places:
|
||||||
out.append(f"2 PLAC {places[e.place_id].name}")
|
out.append(f"2 PLAC {places[e.place_id].name}")
|
||||||
|
out += cite_lines(cite_by_event.get(e.id, []), 2)
|
||||||
|
out += cite_lines(cite_by_person.get(p.id, []), 1)
|
||||||
if p.id in child_fams:
|
if p.id in child_fams:
|
||||||
out.append(f"1 FAMC {child_fams[p.id]}")
|
out.append(f"1 FAMC {child_fams[p.id]}")
|
||||||
for x in spouse_fams.get(p.id, []):
|
for x in spouse_fams.get(p.id, []):
|
||||||
@@ -787,6 +825,8 @@ async def export_gedcom(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tr
|
|||||||
out.append(f"1 {tag}")
|
out.append(f"1 {tag}")
|
||||||
if _ged_date(e.date_value):
|
if _ged_date(e.date_value):
|
||||||
out.append(f"2 DATE {e.date_value}")
|
out.append(f"2 DATE {e.date_value}")
|
||||||
|
out += cite_lines(cite_by_event.get(e.id, []), 2)
|
||||||
|
out += cite_lines(cite_by_rel.get(f["rel_id"], []), 1)
|
||||||
|
|
||||||
for s in sources:
|
for s in sources:
|
||||||
out.append(f"0 {sxref[s.id]} SOUR")
|
out.append(f"0 {sxref[s.id]} SOUR")
|
||||||
|
|||||||
@@ -77,6 +77,58 @@ async def test_gedcom_export_and_reimport(client):
|
|||||||
assert resp.json()["counts"]["relationships"] == 3
|
assert resp.json()["counts"]["relationships"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_gedcom_export_preserves_citations(client):
|
||||||
|
h, tid = await _tree(client, "ged-cite@example.com")
|
||||||
|
pid = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons", json={"given": "Ada", "surname": "Vance"}, headers=h
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
eid = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/events",
|
||||||
|
json={"event_type": "birth", "person_id": pid, "date_value": "1898"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
sid = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/sources", json={"title": "1900 Census"}, headers=h
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
# A person-level and an event-level citation on the same source.
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/citations",
|
||||||
|
json={"source_id": sid, "person_id": pid, "page": "p.12"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/citations",
|
||||||
|
json={"source_id": sid, "event_id": eid, "page": "line 5"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = (await client.get(f"/api/v1/trees/{tid}/gedcom/export", headers=h)).text
|
||||||
|
# Citation links + pages are emitted (previously dropped).
|
||||||
|
assert "1 SOUR @S1@" in text # person-level
|
||||||
|
assert "2 PAGE p.12" in text
|
||||||
|
assert "2 SOUR @S1@" in text # event-level (under 1 BIRT)
|
||||||
|
assert "3 PAGE line 5" in text
|
||||||
|
|
||||||
|
# Round-trip into a fresh tree: the citations survive.
|
||||||
|
tid2 = (await client.post("/api/v1/trees", json={"name": "RT"}, headers=h)).json()["id"]
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid2}/gedcom/import",
|
||||||
|
files={"file": ("rt.ged", text.encode(), "text/plain")},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
cites = (await client.get(f"/api/v1/trees/{tid2}/citations", headers=h)).json()
|
||||||
|
assert len(cites) >= 2
|
||||||
|
assert any(c["person_id"] for c in cites)
|
||||||
|
assert any(c["event_id"] for c in cites)
|
||||||
|
assert {"p.12", "line 5"} <= {c.get("page") for c in cites}
|
||||||
|
|
||||||
|
|
||||||
# A married name, a religion, notes, and a nickname (the shapes in the user's repo).
|
# A married name, a religion, notes, and a nickname (the shapes in the user's repo).
|
||||||
RICH = b"""0 HEAD
|
RICH = b"""0 HEAD
|
||||||
1 CHAR UTF-8
|
1 CHAR UTF-8
|
||||||
|
|||||||
Reference in New Issue
Block a user