Fix #169: keep citation links on GEDCOM export
Export emitted SOUR records but never the per-fact SOUR links, so a Provenance→Provenance round-trip destroyed the sources graph (citations were dropped). Emit citation links on the facts they sit on: - person-level → 1 SOUR @Sx@ (2 PAGE) - name-level → 2 SOUR under 1 NAME - event-level → 2 SOUR under the event (incl. partnership events in FAM) - relationship → 1 SOUR under FAM Citations whose source didn't export are skipped. Test: a person + event citation round-trips through export→import into a fresh tree with their pages intact. GEDCOM suite 6 passed. Closes #169 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Justin Paul <justin@jpaul.me>
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))
|
||||
).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)}
|
||||
gender_by_id = {p.id: p.gender for p in persons}
|
||||
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)
|
||||
for n in sorted(names, key=lambda n: (n.sort_order, not n.is_primary)):
|
||||
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)
|
||||
if 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 "")
|
||||
if 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}")
|
||||
if e.place_id and e.place_id in places:
|
||||
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:
|
||||
out.append(f"1 FAMC {child_fams[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}")
|
||||
if _ged_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:
|
||||
out.append(f"0 {sxref[s.id]} SOUR")
|
||||
|
||||
Reference in New Issue
Block a user