function getAppBase() { const { pathname } = window.location; if (pathname === "/" || pathname === "/index.html") { return ""; } if (pathname.endsWith("/index.html")) { return pathname.slice(0, -"/index.html".length); } return pathname.endsWith("/") ? pathname.slice(0, -1) : pathname; } function getInitialSpeciesSlug() { const hash = window.location.hash.replace(/^#/, "").trim(); return hash || ""; } const apiBase = getAppBase(); const speciesList = document.querySelector("#species-list"); const searchInput = document.querySelector("#search"); const archiveFilterGroup = document.querySelector("#archive-filter-group"); const detailEmpty = document.querySelector("#detail-empty"); const detail = document.querySelector("#detail"); const detailCode = document.querySelector("#detail-code"); const detailCommonName = document.querySelector("#detail-common-name"); const detailArchiveBadge = document.querySelector("#detail-archive-badge"); const detailArchiveNote = document.querySelector("#detail-archive-note"); const detailScientificName = document.querySelector("#detail-scientific-name"); const detailSummary = document.querySelector("#detail-summary"); const detailSections = document.querySelector("#detail-sections"); const legacyPanel = document.querySelector("#legacy-panel"); const legacySourceMeta = document.querySelector("#legacy-source-meta"); const legacySourceText = document.querySelector("#legacy-source-text"); const speciesCount = document.querySelector("#species-count"); const sectionCount = document.querySelector("#section-count"); const authTokenInput = document.querySelector("#auth-token"); const authSaveButton = document.querySelector("#auth-save"); const authClearButton = document.querySelector("#auth-clear"); const authStatus = document.querySelector("#auth-status"); const contributorEmailInput = document.querySelector("#contributor-email"); const contributorAgeGate = document.querySelector("#contributor-age-gate"); const contributorAgeLabel = document.querySelector("#contributor-age-label"); const contributorRegisterButton = document.querySelector("#contributor-register"); const contributorStatus = document.querySelector("#contributor-status"); const contributorCreateButton = document.querySelector("#contributor-create"); const accessPanel = document.querySelector("#access-panel"); const editorPanel = document.querySelector("#editor-panel"); const editorPublicationStatus = document.querySelector("#editor-publication-status"); const editorNotes = document.querySelector("#editor-notes"); const editorIsArchived = document.querySelector("#editor-is-archived"); const editorSaveButton = document.querySelector("#editor-save"); const editorStatus = document.querySelector("#editor-status"); const documentPanel = document.querySelector("#document-panel"); const documentMarkdown = document.querySelector("#document-markdown"); const documentPreview = document.querySelector("#document-preview"); const documentSaveButton = document.querySelector("#document-save"); const documentStatus = document.querySelector("#document-status"); const citationPanel = document.querySelector("#citation-panel"); const citationStatus = document.querySelector("#citation-status"); const citationList = document.querySelector("#citation-list"); const citationBackfillSpeciesButton = document.querySelector("#citation-backfill-species"); const citationEnrichAllButton = document.querySelector("#citation-enrich-all"); const citationMatchDialog = document.querySelector("#citation-match-dialog"); const citationMatchSeed = document.querySelector("#citation-match-seed"); const citationMatchCandidates = document.querySelector("#citation-match-candidates"); const citationMatchStatus = document.querySelector("#citation-match-status"); const citationMatchCloseButton = document.querySelector("#citation-match-close"); const auditPanel = document.querySelector("#audit-panel"); const auditList = document.querySelector("#audit-list"); const collapsibleToggles = document.querySelectorAll(".collapsible-toggle"); let currentItems = []; let currentSlug = null; let currentSession = null; let currentArchiveFilter = "active"; let currentCitationMatch = null; let currentSpeciesCitations = []; let workflowPanelState = { "legacy-panel": false, "access-panel": false, "editor-panel": false, "document-panel": false, "citation-panel": false, "audit-panel": false, }; function setCollapsibleState(panel, expanded) { if (!panel) { return; } panel.classList.toggle("collapsed", !expanded); const toggle = panel.querySelector(".collapsible-toggle"); if (!toggle) { return; } const label = toggle.dataset.label || panel.dataset.label || "Section"; toggle.textContent = `${expanded ? "Hide" : "Show"} ${label}`; toggle.setAttribute("aria-expanded", expanded ? "true" : "false"); workflowPanelState[panel.id] = expanded; } function collapseWorkflowPanels() { [legacyPanel, accessPanel, editorPanel, documentPanel, citationPanel, auditPanel].forEach((panel) => { setCollapsibleState(panel, false); }); } function expandCitationPanel() { setCollapsibleState(citationPanel, true); } function restoreWorkflowPanels() { [legacyPanel, accessPanel, editorPanel, documentPanel, citationPanel, auditPanel].forEach((panel) => { if (!panel || panel.classList.contains("hidden")) { return; } setCollapsibleState(panel, Boolean(workflowPanelState[panel.id])); }); } function renderStructuredBody(body) { const trimmed = String(body || "").trim(); if (!trimmed) { return ""; } const paragraphs = trimmed .split(/\n\s*\n/) .map((paragraph) => paragraph.trim()) .filter(Boolean); return paragraphs .map((paragraph) => { const html = escapeHtml(paragraph).replace(/\n/g, "
"); return `

${html}

`; }) .join(""); } function isCitationHeading(title) { const normalized = String(title || "").trim().replace(/:$/, "").toLowerCase(); return [ "reference numbers", "references", "reference", "citations", "citation", "bibliography", "related references", "related citations", ].includes(normalized); } function parseBibtexFields(draftBibtex) { const fields = {}; const text = String(draftBibtex || ""); const pattern = /([a-zA-Z_]+)\s*=\s*\{([^}]*)\}/g; let match = pattern.exec(text); while (match) { fields[match[1].toLowerCase()] = match[2].trim(); match = pattern.exec(text); } return fields; } function collectBibtexRecords(items) { const seen = new Set(); const records = []; for (const item of items || []) { const draftBibtex = String(item && item.draft_bibtex ? item.draft_bibtex : "").trim(); if (!draftBibtex || seen.has(draftBibtex)) { continue; } seen.add(draftBibtex); records.push(draftBibtex); } return records; } function sanitizeFilenamePart(value, fallback = "records") { const cleaned = String(value || "") .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); return cleaned || fallback; } function downloadBibtexRecords(items, filenameStem) { const records = collectBibtexRecords(items); if (!records.length) { return false; } const blob = new Blob([`${records.join("\n\n")}\n`], { type: "application/x-bibtex;charset=utf-8" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${sanitizeFilenamePart(filenameStem)}.bib`; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.setTimeout(() => URL.revokeObjectURL(url), 0); return true; } function buildPublicCitationText(item) { const fields = parseBibtexFields(item.draft_bibtex || ""); if (item.normalized_text) { return escapeHtml(String(item.normalized_text)); } const author = fields.author || ""; const year = fields.year || ""; const title = fields.title || ""; const venue = fields.journal || fields.booktitle || fields.publisher || ""; const volume = fields.volume || ""; const issue = fields.number || ""; const pages = fields.pages || ""; const parts = []; const lead = [author, year ? `(${year})` : ""].filter(Boolean).join(" "); if (lead) { parts.push(lead); } if (title) { parts.push(title); } const venueBits = [venue, volume ? `${volume}${issue ? `(${issue})` : ""}` : issue ? `(${issue})` : "", pages] .filter(Boolean) .join(", "); if (venueBits) { parts.push(venueBits); } return escapeHtml(parts.join(". ").trim() || String(item.raw_text || "")); } function renderPublicCitationEntry(item) { const fields = parseBibtexFields(item.draft_bibtex || ""); const meta = [ item.legacy_reference_number ? `Imported reference ${escapeHtml(item.legacy_reference_number)}` : "", item.source_type === "editor_added_candidate" ? "Added citation" : "", item.source_type === "editor_selected_candidate" ? "Reviewed citation" : "", ] .filter(Boolean) .join(" • "); const links = [ item.doi ? `DOI` : "", item.source_url ? `Source` : "", item.openalex_id ? `OpenAlex` : "", ] .filter(Boolean) .join(" · "); return `

${buildPublicCitationText(item)}

${meta ? `

${meta}

` : ""} ${links ? `` : ""} ${renderCitationAbstractBlock(item.abstract_text || fields.abstract || "", false)}
`; } function buildPublicBibliographyMarkup(citations, filenameStem) { const records = collectBibtexRecords(citations); const downloadButton = `

${records.length ? `${records.length} BibTeX record${records.length === 1 ? "" : "s"} available for download.` : "No BibTeX records are available for download yet."}

`; return Array.isArray(citations) && citations.length ? `${downloadButton}
${citations.map((item) => renderPublicCitationEntry(item)).join("")}
` : `${downloadButton}

No extracted bibliography entries are available yet.

`; } function renderStructuredNodes(nodes, container, citations, renderState = { renderedBibliography: false }) { for (const node of nodes || []) { const rawTitle = String(node.title || "").trim() || "Untitled section"; const isCitationSection = isCitationHeading(rawTitle); if (isCitationSection && renderState.renderedBibliography) { continue; } const sectionEl = document.createElement("section"); sectionEl.className = "detail-section structured-node"; const depth = Number(node.depth || 2); const headingLevel = Math.min(6, Math.max(3, depth + 1)); const title = escapeHtml(isCitationHeading(rawTitle) ? "Bibliography" : rawTitle); const body = String(node.body || "").trim(); const children = Array.isArray(node.children) ? node.children : []; const citationMarkup = isCitationSection ? buildPublicBibliographyMarkup(citations, `${currentSlug || "ecospecies"}-bibliography`) : ""; sectionEl.innerHTML = ` ${title} ${isCitationSection ? citationMarkup : renderStructuredBody(body)} ${children.length ? '
' : ""} `; if (isCitationSection) { renderState.renderedBibliography = true; } if (children.length) { renderStructuredNodes(children, sectionEl.querySelector(".structured-node-children"), citations, renderState); } container.appendChild(sectionEl); } } function renderPrimaryContent(data) { detailSections.innerHTML = ""; if (data.diagnostics.length) { const diagnosticsEl = document.createElement("section"); diagnosticsEl.className = "detail-section detail-diagnostics"; diagnosticsEl.innerHTML = `

Ingest Diagnostics

`; detailSections.appendChild(diagnosticsEl); } const structuredNodes = data.structured_document && data.structured_document.ast && Array.isArray(data.structured_document.ast.nodes) ? data.structured_document.ast.nodes.filter( (node) => String(node.title || "").trim().toLowerCase() !== "summary", ) : []; if (structuredNodes.length) { renderStructuredNodes(structuredNodes, detailSections, data.citations || [], { renderedBibliography: false }); attachCitationToggleControls(detailSections); const downloadButton = detailSections.querySelector(".bibliography-download-button"); if (downloadButton) { downloadButton.addEventListener("click", () => { const downloaded = downloadBibtexRecords(data.citations || [], `${data.slug || currentSlug || "ecospecies"}-bibliography`); const note = detailSections.querySelector(".public-bibliography-note"); if (note && !downloaded) { note.textContent = "No BibTeX records are available for download yet."; } }); } return; } for (const section of data.sections) { const sectionEl = document.createElement("section"); sectionEl.className = "detail-section"; sectionEl.innerHTML = `

${escapeHtml(section.heading)}

${escapeHtml(section.content)}
`; detailSections.appendChild(sectionEl); } } function renderLegacySource(data) { const legacySource = data.legacy_source; const hasLegacySource = Boolean(legacySource && String(legacySource.text || "").trim()); legacyPanel.classList.toggle("hidden", !hasLegacySource); if (!hasLegacySource) { legacySourceMeta.textContent = ""; legacySourceText.textContent = ""; return; } legacySourceMeta.textContent = legacySource.source_file ? `Original imported file: ${legacySource.source_file}` : "Original imported legacy material"; legacySourceText.textContent = String(legacySource.text || ""); } function getAuthToken() { return window.localStorage.getItem("ecospecies_auth_token") || ""; } function getAuthHeaders() { const token = getAuthToken(); return token ? { Authorization: `Bearer ${token}` } : {}; } function escapeHtml(value) { return value .replaceAll("&", "&") .replaceAll('"', """) .replaceAll("<", "<") .replaceAll(">", ">"); } function normalizeAbstractForDisplay(value) { const raw = String(value || "").trim(); if (!raw) { return ""; } const temp = document.createElement("div"); temp.innerHTML = raw; return temp.textContent .replace(/^abstract\s*[:.\-]?\s*/i, "") .replace(/\s+/g, " ") .trim(); } function parseMarkdownFrontMatter(markdown) { const stripped = markdown.trimStart(); if (!stripped.startsWith("---\n")) { return { metadata: {}, body: markdown }; } const remainder = stripped.slice(4); const separatorIndex = remainder.indexOf("\n---\n"); if (separatorIndex === -1) { return { metadata: {}, body: markdown }; } const metadataBlock = remainder.slice(0, separatorIndex); const body = remainder.slice(separatorIndex + 5); const metadata = {}; for (const line of metadataBlock.split("\n")) { const separator = line.indexOf(":"); if (separator === -1) { continue; } const key = line.slice(0, separator).trim(); const value = line.slice(separator + 1).trim(); if (key) { metadata[key] = value; } } return { metadata, body }; } function renderDocumentPreview(markdown) { const { metadata, body } = parseMarkdownFrontMatter(markdown); const headings = body .split("\n") .map((line) => { const match = line.match(/^(#{2,6})\s+(.+?)\s*$/); if (!match) { return null; } return { depth: match[1].length, title: match[2].trim(), }; }) .filter(Boolean); if (!headings.length && !Object.keys(metadata).length) { documentPreview.innerHTML = `

No headings detected yet.

`; return; } const metadataItems = Object.entries(metadata) .map(([key, value]) => `
  • ${escapeHtml(key)}: ${escapeHtml(value)}
  • `) .join(""); const headingItems = headings .map( (heading) => `
  • ${escapeHtml(heading.title)}
  • `, ) .join(""); documentPreview.innerHTML = ` ${metadataItems ? `` : ""} ${headingItems ? `
      ${headingItems}
    ` : '

    No headings detected yet.

    '} `; } function renderCitationList(items, editable) { citationList.innerHTML = ""; if (!items.length) { citationList.innerHTML = `

    No citations have been extracted yet.

    `; return; } for (const item of items) { const article = document.createElement("article"); article.className = "citation-entry"; const readOnlyMeta = [ item.section_heading ? `Section: ${escapeHtml(item.section_heading)}` : "", item.legacy_reference_number ? `Legacy reference: ${escapeHtml(item.legacy_reference_number)}` : "", item.source_type ? `Source: ${escapeHtml(item.source_type)}` : "", item.enrichment_status ? `Enrichment: ${escapeHtml(item.enrichment_status)}` : "", ] .filter(Boolean) .join(" • "); if (!editable) { article.innerHTML = `

    ${readOnlyMeta}

    ${escapeHtml(item.raw_text || "")}

    Review status: ${escapeHtml(item.review_status || "draft")}

    ${item.doi ? `

    DOI: ${escapeHtml(item.doi)}

    ` : ""} ${item.openalex_id ? `

    OpenAlex: ${escapeHtml(item.openalex_id)}

    ` : ""} ${item.resolver_source_label ? `

    Resolver source: ${escapeHtml(item.resolver_source_label)}

    ` : ""} ${renderCitationAbstractBlock(item.abstract_text || "", false)} ${item.enrichment_error ? `

    ${escapeHtml(item.enrichment_error)}

    ` : ""} ${renderCitationBibtexBlock(item.draft_bibtex || "", false)} `; attachCitationToggleControls(article); citationList.appendChild(article); continue; } article.innerHTML = `

    ${readOnlyMeta}

    ${escapeHtml(item.raw_text || "")}

    ${renderCitationAbstractBlock(item.abstract_text || "", true)} ${renderCitationBibtexBlock(item.draft_bibtex || "", true)} ${item.enrichment_error ? `

    ${escapeHtml(item.enrichment_error)}

    ` : ""}
    `; article.querySelector(".citation-enrich").addEventListener("click", async () => { if (!currentSlug) { return; } citationStatus.textContent = `Running enrichment for citation ${item.position}...`; const { response, data } = await requestJson(`/api/editor/species/${currentSlug}/citations/${item.id}/enrich`, { method: "POST", body: JSON.stringify({}), }); if (!response.ok) { citationStatus.textContent = data.error || "Citation enrichment failed"; return; } citationStatus.textContent = `Citation ${data.citation.position} enrichment ${data.citation.enrichment_status}`; await Promise.all([loadSpecies(currentSlug), loadSpeciesCitations(currentSlug)]); }); article.querySelector(".citation-save").addEventListener("click", async () => { if (!currentSlug) { return; } citationStatus.textContent = `Saving citation ${item.position}...`; const { response, data } = await requestJson(`/api/editor/species/${currentSlug}/citations/${item.id}`, { method: "POST", body: JSON.stringify({ review_status: article.querySelector(".citation-review-status").value, doi: article.querySelector(".citation-doi").value, citation_key: article.querySelector(".citation-key").value, entry_type: article.querySelector(".citation-entry-type").value, normalized_text: article.querySelector(".citation-normalized").value, abstract_text: article.querySelector(".citation-abstract").value, draft_bibtex: article.querySelector(".citation-bibtex-editor").value, }), }); if (!response.ok) { citationStatus.textContent = data.error || "Citation review save failed"; return; } citationStatus.textContent = `Citation ${data.citation.position} saved by ${data.last_modified_by}`; await Promise.all([loadSpecies(currentSlug), loadSpeciesCitations(currentSlug)]); }); article.querySelector(".citation-review-matches").addEventListener("click", async () => { await openCitationMatchDialog(item.id); }); attachCitationToggleControls(article); citationList.appendChild(article); } } function renderCitationAbstractBlock(abstractText, editable) { const text = normalizeAbstractForDisplay(abstractText); if (!text) { return ""; } const label = editable ? "Stored Abstract" : "Abstract"; return `
    `; } function renderCitationBibtexBlock(draftBibtex, editable) { const text = String(draftBibtex || "").trim(); if (!text) { return ""; } const label = editable ? "Stored BibTeX" : "BibTeX"; return `
    `; } function attachCitationToggleControls(root) { const toggles = root.querySelectorAll(".citation-abstract-toggle, .citation-detail-toggle"); for (const toggle of toggles) { const shell = toggle.parentElement; const display = shell && shell.querySelector(".citation-abstract-display, .citation-detail-display"); if (!display) { continue; } const showLabel = toggle.textContent.replace(/^Hide /, "Show ").trim(); const hideLabel = showLabel.replace(/^Show /, "Hide "); toggle.addEventListener("click", () => { const hidden = display.classList.toggle("hidden"); toggle.setAttribute("aria-expanded", hidden ? "false" : "true"); toggle.textContent = hidden ? showLabel : hideLabel; }); } } function renderMetadataTable(fields) { const rows = [ ["Author", fields.author || ""], ["Year", fields.year || ""], ["Title", fields.title || ""], ["Venue", fields.journal || fields.booktitle || fields.publisher || fields.howpublished || ""], ["Volume", fields.volume || ""], ["Issue", fields.number || ""], ["Pages", fields.pages || ""], ["DOI", fields.doi || ""], ] .filter(([, value]) => value) .map( ([label, value]) => `
    ${escapeHtml(label)}${escapeHtml(value)}
    `, ) .join(""); return rows || `

    No structured metadata extracted yet.

    `; } function renderFieldMatches(fieldMatches) { return Object.entries(fieldMatches || {}) .map(([field, detail]) => { const status = String(detail.status || "unknown"); return `
    ${escapeHtml(field)} ${escapeHtml(status)} ${escapeHtml(String(detail.seed || ""))} ${escapeHtml(String(detail.candidate || ""))}
    `; }) .join(""); } function normalizeCitationIdentity(value) { return String(value || "").trim().toLowerCase(); } function candidateAlreadyExists(candidate) { const candidateFields = candidate && candidate.fields ? candidate.fields : {}; const candidateDoi = normalizeCitationIdentity(candidateFields.doi || candidate.doi || ""); const candidateOpenAlex = normalizeCitationIdentity(candidateFields.openalex || candidate.openalex_id || ""); const candidateKey = normalizeCitationIdentity(candidate.citation_key || ""); const candidateText = normalizeCitationIdentity(candidate.normalized_text || ""); return currentSpeciesCitations.some((item) => { const itemDoi = normalizeCitationIdentity(item.doi || ""); const itemOpenAlex = normalizeCitationIdentity(item.openalex_id || ""); const itemKey = normalizeCitationIdentity(item.citation_key || ""); const itemText = normalizeCitationIdentity(item.normalized_text || ""); return ( (candidateDoi && itemDoi && candidateDoi === itemDoi) || (candidateOpenAlex && itemOpenAlex && candidateOpenAlex === itemOpenAlex) || (candidateKey && itemKey && candidateKey === itemKey) || (candidateText && itemText && candidateText === itemText) ); }); } function closeCitationMatchDialog() { currentCitationMatch = null; citationMatchDialog.classList.add("hidden"); citationMatchDialog.setAttribute("aria-hidden", "true"); citationMatchSeed.innerHTML = ""; citationMatchCandidates.innerHTML = ""; citationMatchStatus.textContent = "Compare the parsed source citation against candidate metadata."; } async function applyCitationCandidate(candidate) { if (!currentSlug || !currentCitationMatch) { return; } citationMatchStatus.textContent = `Applying ${candidate.source_label || "candidate"}...`; const { response, data } = await requestJson( `/api/editor/species/${currentSlug}/citations/${currentCitationMatch.citationId}/apply-match`, { method: "POST", body: JSON.stringify({ candidate }), }, ); if (!response.ok) { citationMatchStatus.textContent = data.error || "Candidate application failed"; return; } citationStatus.textContent = `Citation ${data.citation.position} accepted from reviewed candidate`; closeCitationMatchDialog(); expandCitationPanel(); await Promise.all([loadSummary(), loadSpeciesList(searchInput.value), loadSpeciesCitations(currentSlug)]); expandCitationPanel(); } async function addCitationCandidate(candidate) { if (!currentSlug || !currentCitationMatch) { return; } citationMatchStatus.textContent = `Adding ${candidate.source_label || "candidate"} as another citation...`; const { response, data } = await requestJson( `/api/editor/species/${currentSlug}/citations/${currentCitationMatch.citationId}/add-match`, { method: "POST", body: JSON.stringify({ candidate }), }, ); if (!response.ok) { citationMatchStatus.textContent = data.error || "Candidate addition failed"; return; } citationStatus.textContent = `Added reviewed candidate as citation ${data.citation.position}`; citationMatchStatus.textContent = `Added as citation ${data.citation.position}. You can continue reviewing other candidates.`; expandCitationPanel(); await Promise.all([loadSummary(), loadSpeciesList(searchInput.value), loadSpeciesCitations(currentSlug)]); expandCitationPanel(); } async function openCitationMatchDialog(citationId) { if (!currentSlug || !isEditorSession()) { return; } currentCitationMatch = { citationId }; citationMatchDialog.classList.remove("hidden"); citationMatchDialog.setAttribute("aria-hidden", "false"); citationMatchSeed.innerHTML = ""; citationMatchCandidates.innerHTML = ""; citationMatchStatus.textContent = "Loading candidate matches..."; const { response, data } = await requestJson(`/api/editor/species/${currentSlug}/citations/${citationId}/candidates`); if (!response.ok) { citationMatchStatus.textContent = data.error || "Candidate lookup failed"; citationMatchCandidates.innerHTML = `

    ${escapeHtml(data.error || "Unable to load candidates.")}

    `; return; } citationMatchSeed.innerHTML = `

    ${escapeHtml(data.citation.raw_text || "")}

    ${renderMetadataTable((data.seed && data.seed.fields) || {})} ${renderCitationAbstractBlock((data.seed && (data.seed.abstract_text || (data.seed.fields && data.seed.fields.abstract))) || "", false)} ${data.seed && data.seed.normalized_text ? `

    ${escapeHtml(data.seed.normalized_text)}

    ` : ""} `; attachCitationToggleControls(citationMatchSeed); const candidates = Array.isArray(data.candidates) ? data.candidates : []; citationMatchStatus.textContent = `${candidates.length} candidate${candidates.length === 1 ? "" : "s"} found`; if (!candidates.length) { citationMatchCandidates.innerHTML = `

    No close candidates were returned for this citation.

    `; return; } citationMatchCandidates.innerHTML = ""; for (const candidate of candidates) { const alreadyExists = candidateAlreadyExists(candidate); const card = document.createElement("article"); card.className = "match-candidate-card"; card.innerHTML = `
    ${escapeHtml(candidate.fields?.title || "Untitled candidate")} Score ${escapeHtml(String(candidate.score || 0))}

    ${escapeHtml(candidate.source_label || "")}

    ${alreadyExists ? `

    Already present in this species' citation set.

    ` : ""} ${candidate.conflict_reason ? `

    ${escapeHtml(candidate.conflict_reason)}

    ` : ""} ${renderMetadataTable(candidate.fields || {})} ${renderCitationAbstractBlock(candidate.abstract_text || (candidate.fields && candidate.fields.abstract) || "", false)}
    Field Status Source Candidate
    ${renderFieldMatches(candidate.field_matches || {})}
    ${candidate.normalized_text ? `

    ${escapeHtml(candidate.normalized_text)}

    ` : ""}
    `; card.querySelector(".candidate-apply").addEventListener("click", async () => { await applyCitationCandidate(candidate); }); if (!alreadyExists) { card.querySelector(".candidate-add").addEventListener("click", async () => { await addCitationCandidate(candidate); }); } attachCitationToggleControls(card); citationMatchCandidates.appendChild(card); } } async function loadSpeciesDocument(slug) { if (!isEditorSession() && !isContributorSession()) { documentPanel.classList.add("hidden"); return; } documentPanel.classList.remove("hidden"); documentStatus.textContent = "Loading document..."; const path = isEditorSession() ? `/api/editor/species/${slug}/document` : `/api/contributor/species/${slug}/document`; const { response, data } = await requestJson(path); if (!response.ok) { documentMarkdown.value = ""; documentPreview.innerHTML = `

    ${escapeHtml(data.error || "Unable to load document.")}

    `; documentStatus.textContent = data.error || "Document load failed"; return; } documentMarkdown.value = data.markdown || ""; renderDocumentPreview(documentMarkdown.value); documentStatus.textContent = data.updated_by ? `Document last updated by ${data.updated_by}` : "Document loaded"; } async function loadSpeciesCitations(slug, fallbackData = null) { citationPanel.classList.remove("hidden"); citationBackfillSpeciesButton.classList.toggle("hidden", !isEditorSession()); citationEnrichAllButton.classList.toggle("hidden", !isEditorSession()); if (!isEditorSession() && !isContributorSession()) { const items = Array.isArray(fallbackData && fallbackData.citations) ? fallbackData.citations : []; currentSpeciesCitations = items; renderCitationList(items, false); citationStatus.textContent = `${items.length} citation${items.length === 1 ? "" : "s"}`; return; } citationStatus.textContent = "Loading citations..."; const path = isEditorSession() ? `/api/editor/species/${slug}/citations` : `/api/contributor/species/${slug}/citations`; const { response, data } = await requestJson(path); if (!response.ok) { citationList.innerHTML = `

    ${escapeHtml(data.error || "Unable to load citations.")}

    `; citationStatus.textContent = data.error || "Citation load failed"; return; } currentSpeciesCitations = Array.isArray(data.citations) ? data.citations : []; renderCitationList(currentSpeciesCitations, isEditorSession()); citationStatus.textContent = `${data.citation_count || 0} citation${data.citation_count === 1 ? "" : "s"} extracted`; } async function requestJson(path, options = {}) { const headers = new Headers(options.headers || {}); const authHeaders = getAuthHeaders(); for (const [key, value] of Object.entries(authHeaders)) { headers.set(key, value); } if (options.body && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } const response = await fetch(`${apiBase}${path}`, { ...options, headers }); const data = await response.json(); return { response, data }; } function isEditorSession() { return Boolean(currentSession && currentSession.user && ["editor", "admin"].includes(currentSession.user.role)); } function isContributorSession() { return Boolean(currentSession && currentSession.user && currentSession.user.role === "contributor"); } function getVisibleItems(items) { if (!isEditorSession()) { return items; } if (currentArchiveFilter === "archived") { return items.filter((item) => item.is_archived); } if (currentArchiveFilter === "all") { return items; } return items.filter((item) => !item.is_archived); } function syncArchiveFilterUi() { archiveFilterGroup.classList.toggle("hidden", !isEditorSession()); contributorCreateButton.classList.toggle("hidden", !isContributorSession()); for (const button of archiveFilterGroup.querySelectorAll("[data-archive-filter]")) { button.classList.toggle("is-active", button.dataset.archiveFilter === currentArchiveFilter); } } async function loadSession() { const { data } = await requestJson("/api/auth/session"); currentSession = data; if (!isEditorSession()) { currentArchiveFilter = "active"; } contributorAgeLabel.textContent = String(data.minimum_contributor_age || 13); authTokenInput.value = getAuthToken(); if (data.authenticated) { authStatus.textContent = `${data.user.username} (${data.user.role})`; contributorStatus.textContent = isContributorSession() ? "Contributor token stored in this browser." : ""; } else if (data.auth_configured) { authStatus.textContent = "Auth configured, public session"; } else { authStatus.textContent = "Public access"; } syncArchiveFilterUi(); } async function loadSummary() { const { data } = await requestJson("/api/insights/summary"); speciesCount.textContent = data.species_count; sectionCount.textContent = data.section_count; } function renderSpecies(items) { speciesList.innerHTML = ""; const visibleItems = getVisibleItems(items); if (!visibleItems.length) { speciesList.innerHTML = `

    ${isEditorSession() ? "No species match the current archive filter." : "No species match the current search."}

    `; return; } for (const item of visibleItems) { const button = document.createElement("button"); button.className = item.is_archived ? "species-card species-card-archived" : "species-card"; button.type = "button"; const archivedMeta = item.is_archived ? `Archived` : ""; const commonName = item.common_name || item.title; const scientificName = item.scientific_name || "Scientific name missing"; button.innerHTML = ` ${escapeHtml(commonName)} Common name: ${escapeHtml(commonName)} Scientific name: ${escapeHtml(scientificName)} ${escapeHtml(item.publication_status || "published")}${archivedMeta} ${item.diagnostic_count ? `${item.diagnostic_count} ingest flags` : "No ingest flags"} ${escapeHtml((item.summary || "No summary extracted yet.").slice(0, 180))} `; button.addEventListener("click", () => { window.location.hash = item.slug; loadSpecies(item.slug); }); speciesList.appendChild(button); } } function formatIdentifierBanner(item) { if (item.primary_taxon_identifier && item.primary_taxon_authority) { return `${String(item.primary_taxon_authority).toUpperCase()} ${item.primary_taxon_identifier.identifier || ""}`.trim(); } const legacyIdentifier = Array.isArray(item.legacy_identifiers) ? item.legacy_identifiers[0] : null; if (legacyIdentifier && legacyIdentifier.identifier) { const label = legacyIdentifier.label || "Legacy identifier"; return `${label} ${legacyIdentifier.identifier}`; } return "No external taxon identifier assigned"; } async function loadSpeciesList(search = "") { const query = search ? `?search=${encodeURIComponent(search)}` : ""; const path = isEditorSession() ? `/api/editor/species${query}` : isContributorSession() ? `/api/contributor/species${query}` : `/api/species${query}`; const { data } = await requestJson(path); currentItems = data.items; syncArchiveFilterUi(); renderSpecies(currentItems); } async function loadSpecies(slug) { const previousSlug = currentSlug; currentSlug = slug; closeCitationMatchDialog(); const path = isEditorSession() ? `/api/editor/species/${slug}` : isContributorSession() ? `/api/contributor/species/${slug}` : `/api/species/${slug}`; const { response, data } = await requestJson(path); if (!response.ok) { detailEmpty.classList.remove("hidden"); detail.classList.add("hidden"); speciesList.innerHTML = `

    ${escapeHtml(data.error || "Unable to load species.")}

    `; return; } detailEmpty.classList.add("hidden"); detail.classList.remove("hidden"); if (previousSlug !== slug) { collapseWorkflowPanels(); } detailCode.textContent = formatIdentifierBanner(data); detailCommonName.textContent = data.common_name || data.title; detailArchiveBadge.classList.toggle("hidden", !data.is_archived); detailArchiveNote.classList.toggle("hidden", !data.is_archived); detailScientificName.textContent = data.scientific_name || "Scientific name missing in source"; detailSummary.textContent = data.summary || "No summary extracted from the current source file."; editorPanel.classList.toggle("hidden", !isEditorSession()); auditPanel.classList.toggle("hidden", !isEditorSession()); if (isEditorSession()) { editorPublicationStatus.value = data.publication_status || "published"; editorNotes.value = data.editor_notes || ""; editorIsArchived.checked = Boolean(data.is_archived); editorStatus.textContent = data.last_modified_by ? `Last modified by ${data.last_modified_by}` : "Editor session active"; await Promise.all([loadAudit(slug), loadSpeciesDocument(slug), loadSpeciesCitations(slug)]); } else if (isContributorSession()) { editorStatus.textContent = ""; await Promise.all([loadSpeciesDocument(slug), loadSpeciesCitations(slug)]); } else { documentPanel.classList.add("hidden"); await loadSpeciesCitations(slug, data); } renderLegacySource(data); restoreWorkflowPanels(); renderPrimaryContent(data); } function renderAudit(items) { auditList.innerHTML = ""; if (!items.length) { auditList.innerHTML = `

    No audit entries yet.

    `; return; } for (const item of items) { const entry = document.createElement("article"); entry.className = "audit-entry"; const detailRows = Object.entries(item.details) .map(([field, values]) => { if (values && typeof values === "object" && "from" in values && "to" in values) { return `
  • ${escapeHtml(field)}: ${escapeHtml(String(values.from || ""))} -> ${escapeHtml(String(values.to || ""))}
  • `; } return `
  • ${escapeHtml(field)}: ${escapeHtml(String(values ?? ""))}
  • `; }) .join(""); entry.innerHTML = `

    ${escapeHtml(item.changed_by)} • ${escapeHtml(item.changed_at)} • ${escapeHtml(item.action)}

    `; auditList.appendChild(entry); } } async function loadAudit(slug) { if (!isEditorSession()) { return; } const { response, data } = await requestJson(`/api/editor/species/${slug}/audit`); if (!response.ok) { auditList.innerHTML = `

    ${escapeHtml(data.error || "Unable to load audit history.")}

    `; return; } renderAudit(data.items); } async function saveEditorialChanges() { if (!currentSlug || !isEditorSession()) { return; } editorStatus.textContent = "Saving..."; const { response, data } = await requestJson(`/api/editor/species/${currentSlug}/editorial`, { method: "POST", body: JSON.stringify({ publication_status: editorPublicationStatus.value, editor_notes: editorNotes.value, is_archived: editorIsArchived.checked, }), }); if (!response.ok) { editorStatus.textContent = data.error || "Save failed"; return; } editorStatus.textContent = `Saved by ${data.last_modified_by}`; await Promise.all([loadSummary(), loadSpeciesList(searchInput.value), loadSpecies(currentSlug)]); } async function saveDocumentMarkdown() { if (!currentSlug || (!isEditorSession() && !isContributorSession())) { return; } documentStatus.textContent = "Saving document..."; const path = isEditorSession() ? `/api/editor/species/${currentSlug}/document` : `/api/contributor/species/${currentSlug}/document`; const { response, data } = await requestJson(path, { method: "POST", body: JSON.stringify({ markdown: documentMarkdown.value }), }); if (!response.ok) { documentStatus.textContent = data.error || "Document save failed"; return; } renderDocumentPreview(documentMarkdown.value); documentStatus.textContent = `Document saved by ${data.updated_by}`; await Promise.all([loadSummary(), loadSpeciesList(searchInput.value), loadSpecies(currentSlug)]); } async function registerContributor() { contributorStatus.textContent = "Registering contributor..."; const { response, data } = await requestJson("/api/contributor/register", { method: "POST", body: JSON.stringify({ email: contributorEmailInput.value.trim(), age_gate_confirmed: contributorAgeGate.checked, }), }); if (!response.ok) { contributorStatus.textContent = data.error || "Contributor registration failed"; return; } window.localStorage.setItem("ecospecies_auth_token", data.token); authTokenInput.value = data.token; contributorStatus.textContent = data.warning; await loadSession(); await loadSpeciesList(searchInput.value); } async function createContributorDraft() { if (!isContributorSession()) { return; } contributorStatus.textContent = "Creating new contributor draft..."; const { response, data } = await requestJson("/api/contributor/species", { method: "POST", body: JSON.stringify({}), }); if (!response.ok) { contributorStatus.textContent = data.error || "Draft creation failed"; return; } contributorStatus.textContent = "Draft created. Store your token carefully."; await loadSpeciesList(searchInput.value); await loadSpecies(data.slug); } searchInput.addEventListener("input", async (event) => { await loadSpeciesList(event.target.value); }); for (const button of archiveFilterGroup.querySelectorAll("[data-archive-filter]")) { button.addEventListener("click", () => { currentArchiveFilter = button.dataset.archiveFilter || "active"; syncArchiveFilterUi(); renderSpecies(currentItems); }); } for (const button of collapsibleToggles) { button.addEventListener("click", () => { const panel = document.getElementById(button.dataset.target || ""); if (!panel || panel.classList.contains("hidden")) { return; } const expanded = panel.classList.contains("collapsed"); setCollapsibleState(panel, expanded); }); } authSaveButton.addEventListener("click", async () => { const token = authTokenInput.value.trim(); if (token) { window.localStorage.setItem("ecospecies_auth_token", token); } await loadSession(); await loadSpeciesList(searchInput.value); if (currentSlug) { await loadSpecies(currentSlug); } }); authClearButton.addEventListener("click", async () => { window.localStorage.removeItem("ecospecies_auth_token"); authTokenInput.value = ""; contributorStatus.textContent = ""; await loadSession(); await loadSpeciesList(searchInput.value); if (currentSlug) { await loadSpecies(currentSlug); } }); editorSaveButton.addEventListener("click", saveEditorialChanges); documentSaveButton.addEventListener("click", saveDocumentMarkdown); contributorRegisterButton.addEventListener("click", registerContributor); contributorCreateButton.addEventListener("click", createContributorDraft); citationMatchCloseButton.addEventListener("click", closeCitationMatchDialog); citationMatchDialog.querySelector(".match-dialog-backdrop").addEventListener("click", closeCitationMatchDialog); citationBackfillSpeciesButton.addEventListener("click", async () => { if (!currentSlug || !isEditorSession()) { return; } expandCitationPanel(); citationStatus.textContent = "Running citation backfill for this species..."; const { response, data } = await requestJson(`/api/editor/species/${currentSlug}/citations/backfill`, { method: "POST", body: JSON.stringify({}), }); if (!response.ok) { citationStatus.textContent = data.error || "Species citation backfill failed"; return; } citationStatus.textContent = `Species backfill complete: ${data.backfilled_count || 0} checked, ${data.changed_count || 0} changed, ${data.resolved_count || 0} resolved, ${data.unresolved_count || 0} unresolved, ${data.error_count || 0} errors`; await Promise.all([loadSpecies(currentSlug), loadSpeciesCitations(currentSlug)]); }); citationEnrichAllButton.addEventListener("click", async () => { if (!currentSlug || !isEditorSession()) { return; } expandCitationPanel(); citationStatus.textContent = "Running enrichment for all citations..."; const { response, data } = await requestJson(`/api/editor/species/${currentSlug}/citations/enrich`, { method: "POST", body: JSON.stringify({}), }); if (!response.ok) { citationStatus.textContent = data.error || "Batch citation enrichment failed"; return; } citationStatus.textContent = `Batch enrichment complete: ${data.resolved_count || 0} resolved, ${data.unresolved_count || 0} unresolved, ${data.error_count || 0} errors`; await Promise.all([loadSpecies(currentSlug), loadSpeciesCitations(currentSlug)]); }); documentMarkdown.addEventListener("input", () => { renderDocumentPreview(documentMarkdown.value); }); async function bootstrap() { await loadSession(); await Promise.all([loadSummary(), loadSpeciesList()]); const initialSlug = getInitialSpeciesSlug(); if (initialSlug) { await loadSpecies(initialSlug); } } bootstrap().catch((error) => { speciesList.innerHTML = `

    Failed to load data: ${escapeHtml(String(error))}

    `; }); window.addEventListener("hashchange", async () => { const slug = getInitialSpeciesSlug(); if (slug && slug !== currentSlug) { await loadSpecies(slug); } });