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();