import {escapeText} from "fwtoolkit" import {dateToYear, litToText, nameToText} from "@fiduswriter/bibliography-manager/tools" import type {EditorState} from "prosemirror-state" import {getInternalTargets} from "../links.js" import type {Editor} from "../../types.js" interface CitationMatch { id: number entry_key: string author?: string title?: string year?: string } interface CrossRefTarget { id: string text: string } interface BibliographyEntry { id: number entry_key: string author: string title: string year: string source: string entry: any } /** * Create a drop-up widget for the inline reference editor. * @param citationMatches - Array of {id, entry_key, author, title, year} objects * @param selectedIndex - Currently selected index * @param onSelect - Callback when an item is selected * @param crossRefMatches - Array of {id, text} cross-reference objects * @returns The drop-up DOM element */ export function createInlineReferenceDropUp( citationMatches: CitationMatch[], selectedIndex: number, onSelect: (index: number) => void, crossRefMatches: CrossRefTarget[] = [] ): HTMLElement { const container = document.createElement("span") container.classList.add("drop-up-outer", "inline-reference-drop-up") const inner = document.createElement("div") inner.classList.add("drop-up-inner") const hasAnything = citationMatches.length > 0 || crossRefMatches.length > 0 if (!hasAnything) { inner.innerHTML = `
${gettext("No matching sources")}
` } else { if (citationMatches.length > 0) { const citList = document.createElement("ul") citList.classList.add("inline-reference-drop-up-list") citationMatches.forEach((match, index) => { const li = document.createElement("li") li.classList.add("inline-reference-drop-up-item") if (index === selectedIndex) { li.classList.add("fw-selected") } const authorText = match.author ? escapeText(match.author) : "" const titleText = match.title ? escapeText(match.title) : gettext("Untitled") li.innerHTML = ` ${escapeText(match.entry_key)} ${authorText}${match.year ? ` (${escapeText(match.year)})` : ""} — ${titleText} ` li.addEventListener("mousedown", event => { event.preventDefault() event.stopPropagation() onSelect(index) }) citList.appendChild(li) }) inner.appendChild(citList) } if (crossRefMatches.length > 0) { const sep = document.createElement("div") sep.classList.add("inline-reference-drop-up-section-header") sep.textContent = gettext("Cross-references") inner.appendChild(sep) const xrefList = document.createElement("ul") xrefList.classList.add("inline-reference-drop-up-list") crossRefMatches.forEach((target, index) => { const combinedIndex = citationMatches.length + index const li = document.createElement("li") li.classList.add("inline-reference-drop-up-item") if (combinedIndex === selectedIndex) { li.classList.add("fw-selected") } li.innerHTML = ` #${escapeText(target.id)} ${escapeText(target.text)} ` li.addEventListener("mousedown", event => { event.preventDefault() event.stopPropagation() onSelect(combinedIndex) }) xrefList.appendChild(li) }) inner.appendChild(xrefList) } } container.appendChild(inner) return container } /** * Build a flat list of bibliography entries for matching. * Searches both document bibDB and user bibDB. * @param editor - The Editor instance * @returns Array of {id, entry_key, author, title, year, source, entry} * where `entry` is the raw DB object (needed when importing a user entry * into the document bibDB via addReference). */ export function buildBibliographyList(editor: Editor): BibliographyEntry[] { const entries: BibliographyEntry[] = [] const seenKeys = new Set() const addEntries = (db: Record, source: string): void => { if (!db) { return } Object.entries(db).forEach(([id, entry]) => { if (!entry) { return } const key = entry.entry_key || "" if (seenKeys.has(key)) { return } seenKeys.add(key) const fields = entry.fields || {} const authors = fields.author || fields.editor entries.push({ id: Number.parseInt(id), entry_key: key, author: authors ? nameToText(authors) : "", title: fields.title?.length ? litToText(fields.title) : "", year: fields.date ? dateToYear(fields.date) : "", source, entry }) }) } if (editor.mod?.db?.bibDB?.db) { addEntries(editor.mod.db.bibDB.db as Record, "doc") } if (editor.app.bibDB?.db) { addEntries(editor.app.bibDB.db as Record, "user") } return entries } /** * Filter bibliography entries by query string (prefix match on entry_key). * @param entries - Full bibliography list * @param query - Search query * @returns Filtered entries */ export function filterBibliography( entries: BibliographyEntry[], query: string ): BibliographyEntry[] { if (!query) { return entries.slice(0, 50) } const lowerQuery = query.toLowerCase() return entries .filter(entry => { const key = entry.entry_key.toLowerCase() if (key.startsWith(lowerQuery)) { return true } // Fallback: also match against author or title if no key match if (!entry.entry_key) { const author = (entry.author || "").toLowerCase() const title = (entry.title || "").toLowerCase() return author.includes(lowerQuery) || title.includes(lowerQuery) } return false }) .slice(0, 50) } /** * Build a list of cross-reference targets (headings, figures, tables, * code blocks, anchors) from the main document and footnotes. * @param editor - The Editor instance * @returns Array of {id, text} objects */ export function buildCrossRefList(editor: Editor): CrossRefTarget[] { const mainState = editor.view.state as EditorState const language = mainState.doc.attrs.language as string const targets = getInternalTargets(mainState, language, "main") if ( (editor.mod as any).footnotes?.fnEditor?.view?.state ) { targets.push( ...getInternalTargets( (editor.mod as any).footnotes.fnEditor.view.state, language, "foot" ) ) } return targets } /** * Filter cross-reference targets by query string (substring match on text). * @param targets - Full cross-reference list * @param query - Search query (without leading @) * @returns Filtered targets (max 50) */ export function filterCrossRefs( targets: CrossRefTarget[], query: string ): CrossRefTarget[] { if (!query) { return targets.slice(0, 50) } const lq = query.toLowerCase() return targets .filter( t => t.text.toLowerCase().includes(lq) || t.id.toLowerCase().includes(lq) ) .slice(0, 50) }