import type {Node as ProseMirrorNode} from "prosemirror-model" import {BIBLIOGRAPHY_HEADERS} from "@fiduswriter/document/schema/i18n" import {Dialog, cancelPromise} from "fwtoolkit" import type {Editor} from "../types.js" import {RenderCitations} from "./render.js" interface DialogButtonSpec { text?: string classes?: string click?: () => void type?: "cancel" | "close" | "ok" icon?: string } function namePartToString(part: unknown): string { if (typeof part === "string") { return part } if (part && typeof part === "object" && "text" in part) { return String((part as {text?: string}).text || "") } return "" } function authorName(author: Record): string { const family = Array.isArray(author.family) ? author.family.map(namePartToString).join("") : namePartToString(author.family) const given = Array.isArray(author.given) ? author.given.map(namePartToString).join("") : namePartToString(author.given) if (family || given) { return `${family}, ${given}`.replace(/^,\s*|,\s*$/g, "") } const lastName = Array.isArray(author.lastName) ? author.lastName.map(namePartToString).join("") : namePartToString(author.lastName) const firstname = Array.isArray(author.firstname) ? author.firstname.map(namePartToString).join("") : namePartToString(author.firstname) const literal = Array.isArray(author.literal) ? author.literal.map(namePartToString).join("") : namePartToString(author.literal) return lastName || firstname || literal || "" } function itemDateYear(item: Record): string { const fields = (item.fields as Record) || {} const date = fields.date || fields.issued if (typeof date === "string") { return date.substring(0, 4) } if (date && typeof date === "object") { const parts = (date as Record)["date-parts"] if (Array.isArray(parts) && parts.length > 0) { const firstPart = parts[0] if (Array.isArray(firstPart) && firstPart.length > 0) { return String(firstPart[0]) } } } return "" } export class ModCitations { editor: Editor citationType: string fnOverrideElement: HTMLElement | null citRenderer: any constructor(editor: Editor) { editor.mod.citations = this this.editor = editor this.citationType = "" this.fnOverrideElement = false as unknown as null this.citRenderer = false } init(): void { /* Add a style to hold dynamic CSS info about footnote numbering overrides. * Because we need footnotes in the editor and footnotes added through * citations to be numbered but they aren't in the same order in the DOM, * we need to organize the numbering manually. */ this.editor.dom.insertAdjacentHTML( "beforeend", '' ) this.fnOverrideElement = document.getElementById( "footnote-numbering-override" ) } resetCitations(): void { const citations = document.querySelectorAll( "#paper-editable span.citation" ) citations.forEach(citation => (citation.innerHTML = "")) const docBibliography = document.querySelector(".doc-bibliography") const citationsContainer = document.getElementById( "citation-footnote-box-container" ) if (!docBibliography || !citationsContainer) { return } if (docBibliography.innerHTML !== "") { docBibliography.innerHTML = "" } if (citationsContainer.innerHTML !== "") { citationsContainer.innerHTML = "" } this.layoutCitations() } layoutCitations(): void { if (!this.editor.mod.db?.bibDB.db) { // bibliography hasn't been loaded yet return } const emptyCitations = document.querySelectorAll( "#paper-editable span.citation:empty" ) if (emptyCitations.length) { const settings = this.editor.view.state.doc.attrs, bibliographyHeader = settings.bibliography_header[settings.language as string] || BIBLIOGRAPHY_HEADERS[settings.language as keyof typeof BIBLIOGRAPHY_HEADERS] this.citRenderer = new RenderCitations( document.getElementById("paper-editable") as HTMLElement, settings.citationstyle, bibliographyHeader, this.editor.mod.db.bibDB, this.editor.app.csl, false, settings.language ) this.citRenderer .init() .then(() => this.layoutCitationsTwo()) .catch(() => this.recoverAfterCiteProcCrash()) } } recoverAfterCiteProcCrash(): Promise { console.warn( "CitationProcessor crashed. Falling back to simplified citation rendering." ) // Create simplified citation texts for all citations const citationNodes = document.querySelectorAll( "#paper-editable span.citation" ) citationNodes.forEach(citation => { try { // Get citation data from dataset const referencesData = JSON.parse( (citation as HTMLElement).dataset.references || "[]" ) if (!referencesData.length) { citation.innerHTML = "[?]" return } // Create a basic fallback citation text const citationText = referencesData .map((ref: {id?: number; locator?: string}) => { const entryId = ref.id const item = ((this.editor.mod.db as any).bibDB.db as any)[ entryId as number ] if (!item) { return `[${entryId || "?"}]` } // Extract basic author info let authorText = "Unknown Author" if (item.fields.author && item.fields.author.length) { const name = authorName(item.fields.author[0]) authorText = name || "Unknown Author" if (item.fields.author.length > 1) { authorText += " et al." } } // Extract year const year = itemDateYear(item) || "n.d." // Add locator if present const locator = ref.locator ? `, ${ref.locator}` : "" return `(${authorText}, ${year}${locator})` }) .join("; ") citation.innerHTML = citationText } catch (error) { console.error("Error creating fallback citation:", error) citation.innerHTML = "[Citation]" } }) // Create a simplified bibliography const docBibliography = document.querySelector(".doc-bibliography") if (docBibliography) { try { const settings = this.editor.view.state.doc.attrs const bibliographyHeader = settings.bibliography_header[settings.language as string] || BIBLIOGRAPHY_HEADERS[settings.language as keyof typeof BIBLIOGRAPHY_HEADERS] let bibHTML = `

${bibliographyHeader}

` // Collect all citation references const allCitations = Array.from( document.querySelectorAll("#paper-editable span.citation") ) const allRefs = new Set() allCitations.forEach(citation => { try { const refs = JSON.parse( (citation as HTMLElement).dataset.references || "[]" ) refs.forEach((ref: {id: number}) => allRefs.add(ref.id)) } catch (_error) { // Skip invalid references } }) // Create simple bibliography entries const bibDB = (this.editor.mod.db as any).bibDB.db as any Array.from(allRefs) .sort() .forEach(id => { const item = bibDB[id] if (!item) { bibHTML += `
[Missing reference: ${id}]
` return } const authors = item.fields.author?.length ? item.fields.author .map((author: Record) => authorName(author) || "Unknown" ) .join(", ") : "Unknown Author" const itemYear = itemDateYear(item) const year = itemYear ? `(${itemYear})` : "(n.d.)" const title = item.fields.title ?.map((part: {text?: string}) => part.text || "") .join("") || "Untitled" const publisher = item.fields.publisher ?.map((part: {text?: string}) => part.text || "") .join("") || "" const itemType = item.bib_type || "misc" bibHTML += `
${authors} ${year}. ${title}. ${publisher}. [${itemType}]
` }) bibHTML += "
" docBibliography.innerHTML = bibHTML // Add basic bibliography styling let styleEl = document.querySelector(".doc-bibliography-style") if (!styleEl) { this.editor.dom.insertAdjacentHTML( "beforeend", '' ) styleEl = document.querySelector(".doc-bibliography-style") } const basicCSS = ` .csl-bib-body { line-height: 1.35; } .csl-entry { padding-bottom: 1em; padding-left: 2em; text-indent: -2em; } div.csl-entry { cursor: pointer; } div.csl-entry:hover { background-color: #f0f0f0; } ` ;(styleEl as HTMLElement).innerHTML = basicCSS // Rebind click handlers for bibliography entries if (this.editor.docInfo.access_rights === "write") { this.bindBibliographyClicks() } } catch (error) { console.error("Error creating fallback bibliography:", error) docBibliography.innerHTML = "

Bibliography (Error rendering)

" } } // Reset citation type to avoid footnote layout issues this.citationType = "in-text" return Promise.resolve() } bindBibliographyClicks(): void { document.querySelectorAll("div.csl-entry").forEach((el, index) => { el.addEventListener("click", () => { const eID = Number.parseInt( (this.citRenderer.fm.bibliography[0]?.entry_ids[index][0] || (el as HTMLElement).dataset.reference) as string ) this.checkTrackingDialog() .then(() => import("@fiduswriter/bibliography-manager/form")) .then(({BibEntryForm}) => { const form = new BibEntryForm( (this.editor.mod.db as any).bibDB, false, eID as any ) form.init() }) }) }) } checkTrackingDialog(): Promise { if (!this.editor.view.state.doc.attrs.tracked) { return Promise.resolve() } const buttons: DialogButtonSpec[] = [] let dialog: InstanceType const promise = new Promise(resolve => { buttons.push({ type: "cancel", click: () => { dialog.close() resolve(cancelPromise() as unknown as void) } }) buttons.push({ type: "ok", click: () => { dialog.close() resolve() } }) }) dialog = new Dialog({ title: gettext("No tracking"), body: gettext("Changes to citation sources are not being tracked!"), icon: "exclamation-triangle", width: 400, height: 100, buttons }) dialog.open() return promise } layoutCitationsTwo(): void { const citRenderer = this.citRenderer let needFootnoteLayout = false if (this.citationType !== citRenderer.fm.citationType) { // The citation format has changed, so we need to relayout the footnotes as well needFootnoteLayout = true } this.citationType = citRenderer.fm.citationType // Add the rendered html and css of the bibliography to the DOM. const docBibliography = document.querySelector(".doc-bibliography") if (!docBibliography) { return } docBibliography.innerHTML = citRenderer.fm.bibHTML let styleEl = document.querySelector(".doc-bibliography-style") if (!styleEl) { this.editor.dom.insertAdjacentHTML( "beforeend", '' ) styleEl = document.querySelector(".doc-bibliography-style") } let css = citRenderer.fm.bibCSS if (this.editor.docInfo.access_rights === "write") { this.bindBibliographyClicks() css += ` div.csl-entry { cursor: pointer; } div.csl-entry:hover { background-color: grey; }` } if ((styleEl as HTMLElement).innerHTML !== css) { ;(styleEl as HTMLElement).innerHTML = css } const citationsContainer = document.getElementById( "citation-footnote-box-container" ) if (this.citationType === "note") { // Check if there is an empty citation in the main body text (not footnotes) const emptyCitations = document.querySelector("span.citation:empty") if (emptyCitations) { // Find all the citations in the main body text (not footnotes) const citationNodes = document.querySelectorAll( "#document-editable span.citation" ), citationsHTML = citRenderer.fm.citationTexts .slice(0, citationNodes.length) .map( (citText: string) => `
${citText}
` ) .join("") if (citationsContainer!.innerHTML !== citationsHTML) { citationsContainer!.innerHTML = citationsHTML } // The citations have not been filled, so we do so manually. citationNodes.forEach( citationNode => (citationNode.innerHTML = '') ) const footnoteCitationNodes = document.querySelectorAll( "#footnote-box-container span.citation" ) const footnoteCitTexts = citRenderer.fm.citationTexts.slice( citationNodes.length ) footnoteCitTexts.forEach((citText: string, index: number) => { const citationNode = footnoteCitationNodes[index] if (citationNode) { citationNode.innerHTML = citText } }) } } else { if (citationsContainer!.innerHTML !== "") { citationsContainer!.innerHTML = "" } } this.footnoteNumberOverride() if (needFootnoteLayout) { ;((this.editor.mod as any).footnotes.layout as any).updateDOM() } } footnoteNumberOverride(): void { /* Find the order of footnotes and citations in the document and * write CSS to number all the citation footnotes and other footnotes * correspondingly. Update footnote-numbering-override correspondingly. */ let outputCSS = "" if (this.citationType === "note") { let editorFootnoteCounter = 1, citationFootnoteCounter = 1, footnoteCounter = 1 this.editor.view.state.doc.descendants( (node: ProseMirrorNode) => { if ( node.isInline && (node.type.name === "footnote" || node.type.name === "citation") ) { if (node.type.name === "footnote") { outputCSS += `#footnote-box-container .footnote-container:nth-of-type(${editorFootnoteCounter}) > *:first-child::before { content: "${footnoteCounter} "; }\n` editorFootnoteCounter++ } else { outputCSS += `.footnote-citation:nth-of-type(${citationFootnoteCounter})::before { content: "${footnoteCounter} "; }\n` citationFootnoteCounter++ } footnoteCounter++ } } ) } if (this.fnOverrideElement!.innerHTML !== outputCSS) { this.fnOverrideElement!.innerHTML = outputCSS } } }