From 47904034ef3433b8b6e3a6f906b4444d48a29934 Mon Sep 17 00:00:00 2001 From: Ian Beacall Date: Tue, 2 Jun 2026 01:25:04 +0900 Subject: [PATCH] Ahnentafel Ancestor List: Add Excel and GEDCOM export for ancestors Add export UI and functions to download loaded ancestors as Excel (.xlsx) or GEDCOM (.ged). Includes styling for new exportControls, two buttons and click handlers. Implements data collection helpers (collectExportAncestors, collectExportSlots), parent-mode resolution (getEffectiveParentIds), value serialization and trimming, and Excel row/column builders. Creates XLSX workbook (summary, ancestors, optional slots sheet) with safety limits (MAX_EXCEL_SLOT_EXPORT_ROWS, MAX_EXCEL_WIDTH_SAMPLE_ROWS, MAX_EXCEL_CELL_LENGTH) and column width sampling; falls back with user-facing notices on errors. Also implements GEDCOM generation with name/date escaping and family linking, plus a generic downloadBlob helper and filename base generation. --- views/ahnentafel/ahnentafel.css | 10 +- views/ahnentafel/ahnentafel.js | 510 ++++++++++++++++++++++++++++++++ 2 files changed, 519 insertions(+), 1 deletion(-) diff --git a/views/ahnentafel/ahnentafel.css b/views/ahnentafel/ahnentafel.css index aa86c196..40e3448e 100644 --- a/views/ahnentafel/ahnentafel.css +++ b/views/ahnentafel/ahnentafel.css @@ -69,11 +69,19 @@ cursor: default; } -#reportControls { +#reportControls, +#exportControls { border-left: 1px solid #ccc; padding-left: 1em; } +#exportControls { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5em; +} + #reportStatusText { font-size: 0.85em; color: #666; diff --git a/views/ahnentafel/ahnentafel.js b/views/ahnentafel/ahnentafel.js index bb748be9..f750b797 100644 --- a/views/ahnentafel/ahnentafel.js +++ b/views/ahnentafel/ahnentafel.js @@ -47,6 +47,11 @@ window.AhnentafelView = class AhnentafelView extends View { * Display a list of ancestors using the ahnen numbering system. */ window.AhnentafelAncestorList = class AhnentafelAncestorList { + static MAX_EXCEL_SLOT_EXPORT_ROWS = 20000; + static MAX_EXCEL_WIDTH_SAMPLE_ROWS = 1000; + static MAX_EXCEL_CELL_LENGTH = 32000; + static GEDCOM_MONTHS = ["", "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]; + static WANTED_NAME_PARTS = [ "Prefix", "FirstNames", @@ -707,6 +712,10 @@ window.AhnentafelAncestorList = class AhnentafelAncestorList { + + + + `; container.parentNode.insertBefore(optionsContainer, container); @@ -759,6 +768,16 @@ window.AhnentafelAncestorList = class AhnentafelAncestorList { this.saveSettings(); this.applySettings(); }); + + $("#downloadExcel").on("click", (e) => { + e.preventDefault(); + this.exportAncestorsToExcel(); + }); + + $("#downloadGedcom").on("click", (e) => { + e.preventDefault(); + this.exportAncestorsToGedcom(); + }); } addViewSwitcher() { @@ -2320,6 +2339,497 @@ window.AhnentafelAncestorList = class AhnentafelAncestorList { return people; } + collectExportAncestors(maxGeneration = this.maxGeneration) { + const peopleById = new Map(this.ancestors.map((person) => [person.Id, person])); + return this.collectReportAncestors(maxGeneration) + .map((entry) => { + const person = peopleById.get(entry.id); + return person ? { ...entry, person } : null; + }) + .filter(Boolean); + } + + collectExportSlots(maxGeneration = this.maxGeneration) { + const slots = []; + + for (let generation = 1; generation <= maxGeneration; generation++) { + const startAhnentafel = Math.pow(2, generation - 1); + const endAhnentafel = Math.pow(2, generation) - 1; + for (let ahnentafel = startAhnentafel; ahnentafel <= endAhnentafel; ahnentafel++) { + const person = this.findPersonByAhnentafelNumber(ahnentafel); + if (!person) { + continue; + } + slots.push({ + ahnentafel, + generation, + person, + }); + } + } + + return slots; + } + + countExportSlots(maxGeneration = this.maxGeneration) { + return this.collectExportSlots(maxGeneration).length; + } + + getEffectiveParentIds(person) { + const requestedMode = this.parentModeMap.get(person.Id) || "adoptive"; + const useBioFather = requestedMode === "bio" && !!person.BioFather; + const useBioMother = requestedMode === "bio" && !!person.BioMother; + const fatherId = useBioFather ? person.BioFather || null : person.Father || null; + const motherId = useBioMother ? person.BioMother || null : person.Mother || null; + const fatherType = useBioFather ? "bio" : person.BioFather && person.BioFather !== person.Father ? "adoptive" : "normal"; + const motherType = useBioMother ? "bio" : person.BioMother && person.BioMother !== person.Mother ? "adoptive" : "normal"; + const presentParentTypes = [fatherId ? fatherType : null, motherId ? motherType : null].filter(Boolean); + const uniqueParentTypes = [...new Set(presentParentTypes.length ? presentParentTypes : ["normal"])]; + + return { + fatherId, + motherId, + fatherType, + motherType, + mode: uniqueParentTypes.length === 1 ? uniqueParentTypes[0] : "mixed", + }; + } + + serializeExportValue(value) { + if (value === null || typeof value === "undefined") { + return ""; + } + + let serializedValue; + if (Array.isArray(value)) { + serializedValue = value.join(" | "); + } else if (typeof value === "object") { + try { + serializedValue = JSON.stringify(value); + } catch (error) { + serializedValue = String(value); + } + } else { + serializedValue = String(value); + } + + if (serializedValue.length > AhnentafelAncestorList.MAX_EXCEL_CELL_LENGTH) { + return `${serializedValue.slice(0, AhnentafelAncestorList.MAX_EXCEL_CELL_LENGTH - 15)}...[truncated]`; + } + + return serializedValue; + } + + buildExcelExportPersonRow(person, peopleById) { + const { fatherId, motherId, fatherType, motherType, mode } = this.getEffectiveParentIds(person); + const effectiveFather = fatherId ? peopleById.get(String(fatherId)) : null; + const effectiveMother = motherId ? peopleById.get(String(motherId)) : null; + + return { + ParentMode: mode, + Id: person.Id || "", + Name: person.Name || "", + FirstName: this.serializeExportValue(person.FirstName), + MiddleName: this.serializeExportValue(person.MiddleName), + LastNameAtBirth: this.serializeExportValue(person.LastNameAtBirth), + LastNameCurrent: this.serializeExportValue(person.LastNameCurrent), + RealName: this.serializeExportValue(person.RealName), + Nicknames: this.serializeExportValue(person.Nicknames), + Suffix: this.serializeExportValue(person.Suffix), + Gender: this.serializeExportValue(person.Gender), + BirthDate: this.serializeExportValue(person.BirthDate), + BirthLocation: this.serializeExportValue(person.BirthLocation), + DeathDate: this.serializeExportValue(person.DeathDate), + DeathLocation: this.serializeExportValue(person.DeathLocation), + Father: this.serializeExportValue(person.Father), + Mother: this.serializeExportValue(person.Mother), + BioFather: this.serializeExportValue(person.BioFather), + BioMother: this.serializeExportValue(person.BioMother), + EffectiveFatherId: fatherId || "", + EffectiveFatherType: fatherId ? fatherType : "", + EffectiveFatherWtId: effectiveFather?.Name || "", + EffectiveMotherId: motherId || "", + EffectiveMotherType: motherId ? motherType : "", + EffectiveMotherWtId: effectiveMother?.Name || "", + Privacy: this.serializeExportValue(person.Privacy), + }; + } + + getExcelAncestorsColumns() { + return [ + "PrimaryAhnentafel", + "PrimaryGeneration", + "ParentMode", + "Id", + "Name", + "FirstName", + "MiddleName", + "LastNameAtBirth", + "LastNameCurrent", + "RealName", + "Nicknames", + "Suffix", + "Gender", + "BirthDate", + "BirthLocation", + "DeathDate", + "DeathLocation", + "Father", + "Mother", + "BioFather", + "BioMother", + "EffectiveFatherId", + "EffectiveFatherType", + "EffectiveFatherWtId", + "EffectiveMotherId", + "EffectiveMotherType", + "EffectiveMotherWtId", + "Privacy", + ]; + } + + getExcelSlotsColumns() { + return [ + "SlotAhnentafel", + "SlotGeneration", + "ParentMode", + "Id", + "Name", + "FirstName", + "MiddleName", + "LastNameAtBirth", + "LastNameCurrent", + "RealName", + "Nicknames", + "Suffix", + "Gender", + "BirthDate", + "BirthLocation", + "DeathDate", + "DeathLocation", + "Father", + "Mother", + "BioFather", + "BioMother", + "EffectiveFatherId", + "EffectiveFatherType", + "EffectiveFatherWtId", + "EffectiveMotherId", + "EffectiveMotherType", + "EffectiveMotherWtId", + "Privacy", + ]; + } + + makeExportFileBase() { + const rootPerson = this.ancestors.find((person) => person.Id === this.startId); + const rootId = rootPerson?.Name || `person_${this.startId}`; + const safeRootId = String(rootId).replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^_+|_+$/g, "") || `person_${ + this.startId + }`; + const timestamp = new Date().toISOString().replace(/\.[0-9]{3}Z$/, "Z").replace(/:/g, "-").replace("T", "_"); + return `ahnentafel_${safeRootId}_${timestamp}`; + } + + downloadBlob(blob, fileName) { + if (typeof saveAs === "function") { + saveAs(blob, fileName); + return; + } + + const blobUrl = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = blobUrl; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + setTimeout(() => URL.revokeObjectURL(blobUrl), 0); + } + + getExcelColumnWidths(columns, rows) { + const sampledRows = rows.slice(0, AhnentafelAncestorList.MAX_EXCEL_WIDTH_SAMPLE_ROWS); + + return columns.map((column) => { + let maxLength = column.length; + sampledRows.forEach((row) => { + maxLength = Math.max(maxLength, String(row?.[column] ?? "").length); + }); + return { wch: Math.min(60, Math.max(12, maxLength + 2)) }; + }); + } + + createExcelSheetFromRows(columns, rows) { + const sheet = XLSX.utils.json_to_sheet(rows, { header: columns }); + sheet["!cols"] = this.getExcelColumnWidths(columns, rows); + return sheet; + } + + exportAncestorsToExcel() { + const exportAncestors = this.collectExportAncestors(this.maxGeneration); + if (exportAncestors.length === 0) { + wtViewRegistry.showError("No ancestor data is currently loaded to export."); + return; + } + + const exportSlots = this.collectExportSlots(this.maxGeneration); + const exportSlotCount = exportSlots.length; + const includeSlotsSheet = exportSlotCount <= AhnentafelAncestorList.MAX_EXCEL_SLOT_EXPORT_ROWS; + + const peopleById = new Map(exportAncestors.map((entry) => [String(entry.id), entry.person])); + const rowData = exportAncestors.map(({ person, generation, ahnentafel }) => ({ + PrimaryAhnentafel: ahnentafel, + PrimaryGeneration: generation, + ...this.buildExcelExportPersonRow(person, peopleById), + })); + const columns = this.getExcelAncestorsColumns(); + const ancestorsSheet = this.createExcelSheetFromRows(columns, rowData); + + let slotsSheet = null; + if (includeSlotsSheet) { + const slotRowData = exportSlots.map(({ person, generation, ahnentafel }) => ({ + SlotAhnentafel: ahnentafel, + SlotGeneration: generation, + ...this.buildExcelExportPersonRow(person, peopleById), + })); + const slotColumns = this.getExcelSlotsColumns(); + slotsSheet = this.createExcelSheetFromRows(slotColumns, slotRowData); + } + + const rootPerson = this.ancestors.find((person) => person.Id === this.startId); + const summarySheet = XLSX.utils.aoa_to_sheet([ + ["Root Person", rootPerson ? this.getDisplayName(rootPerson) : this.startId], + ["WikiTree ID", rootPerson?.Name || ""], + ["Generations Loaded", this.maxGeneration], + ["Unique Profiles Exported", exportAncestors.length], + ["Ahnentafel Slots Loaded", exportSlotCount], + [ + "Ahnentafel Slots Sheet Included", + includeSlotsSheet + ? "Yes" + : `No - omitted because ${exportSlotCount} slots exceeds the in-browser Excel safety limit of ${AhnentafelAncestorList.MAX_EXCEL_SLOT_EXPORT_ROWS}`, + ], + ["Exported At", new Date().toISOString()], + ]); + + const workbook = XLSX.utils.book_new(); + workbook.Props = { + Title: `Ahnentafel Ancestors for ${rootPerson?.Name || this.startId}`, + Subject: "Ancestor export", + Author: "WikiTree", + CreatedDate: new Date(), + }; + XLSX.utils.book_append_sheet(workbook, summarySheet, "Summary"); + XLSX.utils.book_append_sheet(workbook, ancestorsSheet, "Ancestors"); + if (slotsSheet) { + XLSX.utils.book_append_sheet(workbook, slotsSheet, "Ahnentafel Slots"); + } + + try { + const workbookData = XLSX.write(workbook, { bookType: "xlsx", type: "array" }); + this.downloadBlob( + new Blob([workbookData], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }), + `${this.makeExportFileBase()}.xlsx` + ); + wtViewRegistry.showNotice( + includeSlotsSheet + ? `Downloaded Excel export for ${exportAncestors.length} ancestors.` + : `Downloaded Excel export for ${exportAncestors.length} ancestors. The Ahnentafel Slots sheet was omitted because ${exportSlotCount} slot rows would likely overwhelm the browser.` + ); + } catch (error) { + console.error("Excel export failed", error); + wtViewRegistry.showError( + /32767/.test(error?.message || "") + ? "Excel export failed because one or more cells exceeded Excel's text-length limit. The export now omits bulky columns like photo and data status fields; reload the page and try again." + : "Excel export failed. The loaded tree is too large to build a full workbook in the browser. Try GEDCOM or export fewer generations." + ); + } + } + + escapeGedcomText(value) { + return String(value || "") + .replace(/[\r\n]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + } + + formatGedcomDate(date) { + if (!date || date === "0000-00-00") { + return ""; + } + + const [year, month, day] = String(date).split("-"); + if (!year || year === "0000") { + return ""; + } + + const parts = []; + if (day && day !== "00") { + parts.push(String(parseInt(day, 10))); + } + if (month && month !== "00") { + parts.push(AhnentafelAncestorList.GEDCOM_MONTHS[parseInt(month, 10)]); + } + parts.push(year); + return parts.join(" "); + } + + formatGedcomHeaderDate(date) { + const day = date.getDate(); + const month = AhnentafelAncestorList.GEDCOM_MONTHS[date.getMonth() + 1]; + const year = date.getFullYear(); + return `${day} ${month} ${year}`; + } + + formatGedcomName(person) { + const givenNames = [person.Prefix, person.FirstName || person.RealName, person.MiddleName].filter(Boolean).join(" "); + const surname = person.LastNameAtBirth || person.LastNameCurrent || ""; + const suffix = person.Suffix ? ` ${person.Suffix}` : ""; + if (givenNames || surname) { + return this.escapeGedcomText(`${givenNames}${surname ? ` /${surname}/` : ""}${suffix}`.trim()); + } + + return this.escapeGedcomText(this.getDisplayName(person)); + } + + exportAncestorsToGedcom() { + const exportAncestors = this.collectExportAncestors(this.maxGeneration); + if (exportAncestors.length === 0) { + wtViewRegistry.showError("No ancestor data is currently loaded to export."); + return; + } + + const peopleById = new Map(exportAncestors.map((entry) => [String(entry.id), entry.person])); + const familiesByKey = new Map(); + + exportAncestors.forEach(({ id, person }) => { + const { fatherId, motherId } = this.getEffectiveParentIds(person); + const normalizedFatherId = fatherId && peopleById.has(String(fatherId)) ? String(fatherId) : ""; + const normalizedMotherId = motherId && peopleById.has(String(motherId)) ? String(motherId) : ""; + + if (!normalizedFatherId && !normalizedMotherId) { + return; + } + + const familyKey = `${normalizedFatherId || 0}-${normalizedMotherId || 0}`; + if (!familiesByKey.has(familyKey)) { + familiesByKey.set(familyKey, { + fatherId: normalizedFatherId, + motherId: normalizedMotherId, + children: new Set(), + }); + } + familiesByKey.get(familyKey).children.add(String(id)); + }); + + const families = Array.from(familiesByKey.values()).map((family, index) => ({ + ...family, + gedcomId: `F${index + 1}`, + children: [...family.children], + })); + const familyLinks = new Map(exportAncestors.map(({ id }) => [String(id), { famc: "", fams: new Set() }])); + + families.forEach((family) => { + if (family.fatherId && familyLinks.has(family.fatherId)) { + familyLinks.get(family.fatherId).fams.add(family.gedcomId); + } + if (family.motherId && familyLinks.has(family.motherId)) { + familyLinks.get(family.motherId).fams.add(family.gedcomId); + } + family.children.forEach((childId) => { + if (familyLinks.has(childId)) { + familyLinks.get(childId).famc = family.gedcomId; + } + }); + }); + + const now = new Date(); + const lines = [ + "0 HEAD", + "1 SOUR WikiTreeDynamicTree", + "2 NAME WikiTree Dynamic Tree", + "2 VERS 1.0", + "1 DEST ANY", + `1 DATE ${this.formatGedcomHeaderDate(now)}`, + `2 TIME ${now.toTimeString().slice(0, 8)}`, + "1 GEDC", + "2 VERS 5.5.1", + "2 FORM LINEAGE-LINKED", + "1 CHAR UTF-8", + ]; + + exportAncestors.forEach(({ id, generation, ahnentafel, person }) => { + const personLinks = familyLinks.get(String(id)) || { famc: "", fams: new Set() }; + lines.push(`0 @I${id}@ INDI`); + + const gedcomName = this.formatGedcomName(person); + if (gedcomName) { + lines.push(`1 NAME ${gedcomName}`); + } + + const sex = person.Gender === "Male" ? "M" : person.Gender === "Female" ? "F" : "U"; + lines.push(`1 SEX ${sex}`); + + if (person.Name) { + lines.push(`1 REFN ${this.escapeGedcomText(person.Name)}`); + } + lines.push( + `1 NOTE WikiTree ID: ${this.escapeGedcomText(person.Name || "")}; Ahnentafel: ${ahnentafel}; Generation: ${generation}` + ); + + const birthDate = this.formatGedcomDate(person.BirthDate); + const birthPlace = this.escapeGedcomText(person.BirthLocation); + if (birthDate || birthPlace) { + lines.push("1 BIRT"); + if (birthDate) { + lines.push(`2 DATE ${birthDate}`); + } + if (birthPlace) { + lines.push(`2 PLAC ${birthPlace}`); + } + } + + const deathDate = this.formatGedcomDate(person.DeathDate); + const deathPlace = this.escapeGedcomText(person.DeathLocation); + if (deathDate || deathPlace) { + lines.push("1 DEAT"); + if (deathDate) { + lines.push(`2 DATE ${deathDate}`); + } + if (deathPlace) { + lines.push(`2 PLAC ${deathPlace}`); + } + } + + if (personLinks.famc) { + lines.push(`1 FAMC @${personLinks.famc}@`); + } + personLinks.fams.forEach((familyId) => { + lines.push(`1 FAMS @${familyId}@`); + }); + }); + + families.forEach((family) => { + lines.push(`0 @${family.gedcomId}@ FAM`); + if (family.fatherId) { + lines.push(`1 HUSB @I${family.fatherId}@`); + } + if (family.motherId) { + lines.push(`1 WIFE @I${family.motherId}@`); + } + family.children.forEach((childId) => { + lines.push(`1 CHIL @I${childId}@`); + }); + }); + + lines.push("0 TRLR"); + this.downloadBlob( + new Blob([`${lines.join("\n")}\n`], { type: "text/plain;charset=utf-8" }), + `${this.makeExportFileBase()}.ged` + ); + wtViewRegistry.showNotice(`Downloaded GEDCOM export for ${exportAncestors.length} ancestors.`); + } + buildReportShell(rootPerson) { const $report = $("#ahnentafelReport"); $report.empty();