1333 lines
51 KiB
JavaScript
1333 lines
51 KiB
JavaScript
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, "<br>");
|
|
return `<p class="structured-node-body">${html}</p>`;
|
|
})
|
|
.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 ? `<a href="https://doi.org/${encodeURIComponent(String(item.doi).replace(/^https?:\/\/doi\.org\//, ""))}" target="_blank" rel="noopener noreferrer">DOI</a>` : "",
|
|
item.source_url ? `<a href="${escapeHtml(item.source_url)}" target="_blank" rel="noopener noreferrer">Source</a>` : "",
|
|
item.openalex_id ? `<a href="https://openalex.org/${escapeHtml(String(item.openalex_id).replace(/^https?:\/\/openalex\.org\//, ""))}" target="_blank" rel="noopener noreferrer">OpenAlex</a>` : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" · ");
|
|
|
|
return `
|
|
<article class="public-citation-entry">
|
|
<p class="public-citation-text">${buildPublicCitationText(item)}</p>
|
|
${meta ? `<p class="public-citation-meta">${meta}</p>` : ""}
|
|
${links ? `<p class="public-citation-links">${links}</p>` : ""}
|
|
${renderCitationAbstractBlock(item.abstract_text || fields.abstract || "", false)}
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
function buildPublicBibliographyMarkup(citations, filenameStem) {
|
|
const records = collectBibtexRecords(citations);
|
|
const downloadButton = `
|
|
<div class="public-bibliography-actions">
|
|
<button
|
|
type="button"
|
|
class="secondary-button bibliography-download-button"
|
|
data-filename-stem="${escapeHtml(filenameStem)}"
|
|
${records.length ? "" : "disabled"}
|
|
>
|
|
Download BibTeX
|
|
</button>
|
|
<p class="public-bibliography-note">
|
|
${records.length ? `${records.length} BibTeX record${records.length === 1 ? "" : "s"} available for download.` : "No BibTeX records are available for download yet."}
|
|
</p>
|
|
</div>
|
|
`;
|
|
|
|
return Array.isArray(citations) && citations.length
|
|
? `${downloadButton}<div class="public-citation-list">${citations.map((item) => renderPublicCitationEntry(item)).join("")}</div>`
|
|
: `${downloadButton}<p class="structured-node-body">No extracted bibliography entries are available yet.</p>`;
|
|
}
|
|
|
|
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 = `
|
|
<h${headingLevel}>${title}</h${headingLevel}>
|
|
${isCitationSection ? citationMarkup : renderStructuredBody(body)}
|
|
${children.length ? '<div class="structured-node-children"></div>' : ""}
|
|
`;
|
|
|
|
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 = `
|
|
<h3>Ingest Diagnostics</h3>
|
|
<ul class="diagnostic-list">
|
|
${data.diagnostics
|
|
.map(
|
|
(diagnostic) =>
|
|
`<li><strong>${escapeHtml(diagnostic.code)}</strong>: ${escapeHtml(diagnostic.message)}</li>`,
|
|
)
|
|
.join("")}
|
|
</ul>
|
|
`;
|
|
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 = `
|
|
<h3>${escapeHtml(section.heading)}</h3>
|
|
<pre>${escapeHtml(section.content)}</pre>
|
|
`;
|
|
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 = `<p class="document-preview-empty">No headings detected yet.</p>`;
|
|
return;
|
|
}
|
|
|
|
const metadataItems = Object.entries(metadata)
|
|
.map(([key, value]) => `<li><strong>${escapeHtml(key)}</strong>: ${escapeHtml(value)}</li>`)
|
|
.join("");
|
|
|
|
const headingItems = headings
|
|
.map(
|
|
(heading) =>
|
|
`<li style="margin-left:${Math.max(0, heading.depth - 2) * 18}px">${escapeHtml(heading.title)}</li>`,
|
|
)
|
|
.join("");
|
|
|
|
documentPreview.innerHTML = `
|
|
${metadataItems ? `<ul class="document-preview-metadata">${metadataItems}</ul>` : ""}
|
|
${headingItems ? `<ol class="document-preview-list">${headingItems}</ol>` : '<p class="document-preview-empty">No headings detected yet.</p>'}
|
|
`;
|
|
}
|
|
|
|
function renderCitationList(items, editable) {
|
|
citationList.innerHTML = "";
|
|
if (!items.length) {
|
|
citationList.innerHTML = `<p class="editor-status">No citations have been extracted yet.</p>`;
|
|
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 = `
|
|
<p class="citation-entry-meta">${readOnlyMeta}</p>
|
|
<p class="citation-entry-raw">${escapeHtml(item.raw_text || "")}</p>
|
|
<p class="citation-entry-meta">Review status: ${escapeHtml(item.review_status || "draft")}</p>
|
|
${item.doi ? `<p class="citation-entry-meta">DOI: ${escapeHtml(item.doi)}</p>` : ""}
|
|
${item.openalex_id ? `<p class="citation-entry-meta">OpenAlex: ${escapeHtml(item.openalex_id)}</p>` : ""}
|
|
${item.resolver_source_label ? `<p class="citation-entry-meta">Resolver source: ${escapeHtml(item.resolver_source_label)}</p>` : ""}
|
|
${renderCitationAbstractBlock(item.abstract_text || "", false)}
|
|
${item.enrichment_error ? `<p class="citation-entry-meta error">${escapeHtml(item.enrichment_error)}</p>` : ""}
|
|
${renderCitationBibtexBlock(item.draft_bibtex || "", false)}
|
|
`;
|
|
attachCitationToggleControls(article);
|
|
citationList.appendChild(article);
|
|
continue;
|
|
}
|
|
|
|
article.innerHTML = `
|
|
<p class="citation-entry-meta">${readOnlyMeta}</p>
|
|
<p class="citation-entry-raw">${escapeHtml(item.raw_text || "")}</p>
|
|
<label class="editor-label">Review Status</label>
|
|
<select class="citation-review-status">
|
|
<option value="draft"${item.review_status === "draft" ? " selected" : ""}>Draft</option>
|
|
<option value="reviewed"${item.review_status === "reviewed" ? " selected" : ""}>Reviewed</option>
|
|
<option value="accepted"${item.review_status === "accepted" ? " selected" : ""}>Accepted</option>
|
|
<option value="rejected"${item.review_status === "rejected" ? " selected" : ""}>Rejected</option>
|
|
</select>
|
|
<label class="editor-label">DOI</label>
|
|
<input class="citation-doi" type="text" value="${escapeHtml(item.doi || "")}">
|
|
<label class="editor-label">OpenAlex ID</label>
|
|
<input class="citation-openalex" type="text" value="${escapeHtml(item.openalex_id || "")}" disabled>
|
|
<label class="editor-label">Source URL</label>
|
|
<input class="citation-source-url" type="text" value="${escapeHtml(item.source_url || "")}" disabled>
|
|
<label class="editor-label">Resolver Source</label>
|
|
<input class="citation-resolver-source" type="text" value="${escapeHtml(item.resolver_source_label || "")}" disabled>
|
|
<label class="editor-label">Citation Key</label>
|
|
<input class="citation-key" type="text" value="${escapeHtml(item.citation_key || "")}">
|
|
<label class="editor-label">Entry Type</label>
|
|
<input class="citation-entry-type" type="text" value="${escapeHtml(item.entry_type || "misc")}">
|
|
<label class="editor-label">Normalized Citation</label>
|
|
<textarea class="citation-normalized" rows="3">${escapeHtml(item.normalized_text || "")}</textarea>
|
|
<label class="editor-label">Abstract</label>
|
|
<textarea class="citation-abstract" rows="5">${escapeHtml(item.abstract_text || "")}</textarea>
|
|
${renderCitationAbstractBlock(item.abstract_text || "", true)}
|
|
<label class="editor-label">Draft BibTeX</label>
|
|
<textarea class="citation-bibtex-editor" rows="8">${escapeHtml(item.draft_bibtex || "")}</textarea>
|
|
${renderCitationBibtexBlock(item.draft_bibtex || "", true)}
|
|
${item.enrichment_error ? `<p class="citation-entry-meta error">${escapeHtml(item.enrichment_error)}</p>` : ""}
|
|
<div class="editor-actions">
|
|
<button type="button" class="secondary-button citation-enrich">Run Enrichment</button>
|
|
<button type="button" class="secondary-button citation-review-matches">Review Matches</button>
|
|
<button type="button" class="citation-save">Save Citation Review</button>
|
|
</div>
|
|
`;
|
|
|
|
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 `
|
|
<div class="citation-abstract-shell">
|
|
<button type="button" class="secondary-button citation-abstract-toggle" aria-expanded="false">
|
|
Show ${label}
|
|
</button>
|
|
<div class="citation-abstract-display hidden">
|
|
<p class="public-citation-abstract">${escapeHtml(text)}</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderCitationBibtexBlock(draftBibtex, editable) {
|
|
const text = String(draftBibtex || "").trim();
|
|
if (!text) {
|
|
return "";
|
|
}
|
|
const label = editable ? "Stored BibTeX" : "BibTeX";
|
|
return `
|
|
<div class="citation-detail-shell">
|
|
<button type="button" class="secondary-button citation-detail-toggle" aria-expanded="false">
|
|
Show ${label}
|
|
</button>
|
|
<div class="citation-detail-display hidden">
|
|
<pre class="citation-bibtex">${escapeHtml(text)}</pre>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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]) =>
|
|
`<div class="match-row"><span class="match-label">${escapeHtml(label)}</span><span>${escapeHtml(value)}</span></div>`,
|
|
)
|
|
.join("");
|
|
return rows || `<p class="editor-status">No structured metadata extracted yet.</p>`;
|
|
}
|
|
|
|
function renderFieldMatches(fieldMatches) {
|
|
return Object.entries(fieldMatches || {})
|
|
.map(([field, detail]) => {
|
|
const status = String(detail.status || "unknown");
|
|
return `
|
|
<div class="match-row">
|
|
<span class="match-label">${escapeHtml(field)}</span>
|
|
<span class="match-status match-status-${escapeHtml(status)}">${escapeHtml(status)}</span>
|
|
<span>${escapeHtml(String(detail.seed || ""))}</span>
|
|
<span>${escapeHtml(String(detail.candidate || ""))}</span>
|
|
</div>
|
|
`;
|
|
})
|
|
.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 = `<p class="error">${escapeHtml(data.error || "Unable to load candidates.")}</p>`;
|
|
return;
|
|
}
|
|
|
|
citationMatchSeed.innerHTML = `
|
|
<p class="citation-entry-raw">${escapeHtml(data.citation.raw_text || "")}</p>
|
|
${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 ? `<p class="editor-status">${escapeHtml(data.seed.normalized_text)}</p>` : ""}
|
|
`;
|
|
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 = `<p class="editor-status">No close candidates were returned for this citation.</p>`;
|
|
return;
|
|
}
|
|
|
|
citationMatchCandidates.innerHTML = "";
|
|
for (const candidate of candidates) {
|
|
const alreadyExists = candidateAlreadyExists(candidate);
|
|
const card = document.createElement("article");
|
|
card.className = "match-candidate-card";
|
|
card.innerHTML = `
|
|
<div class="match-candidate-header">
|
|
<strong>${escapeHtml(candidate.fields?.title || "Untitled candidate")}</strong>
|
|
<span class="match-score">Score ${escapeHtml(String(candidate.score || 0))}</span>
|
|
</div>
|
|
<p class="citation-entry-meta">${escapeHtml(candidate.source_label || "")}</p>
|
|
${alreadyExists ? `<p class="citation-entry-meta">Already present in this species' citation set.</p>` : ""}
|
|
${candidate.conflict_reason ? `<p class="citation-entry-meta error">${escapeHtml(candidate.conflict_reason)}</p>` : ""}
|
|
${renderMetadataTable(candidate.fields || {})}
|
|
${renderCitationAbstractBlock(candidate.abstract_text || (candidate.fields && candidate.fields.abstract) || "", false)}
|
|
<div class="match-table">
|
|
<div class="match-row match-row-head">
|
|
<span class="match-label">Field</span>
|
|
<span>Status</span>
|
|
<span>Source</span>
|
|
<span>Candidate</span>
|
|
</div>
|
|
${renderFieldMatches(candidate.field_matches || {})}
|
|
</div>
|
|
${candidate.normalized_text ? `<p class="editor-status">${escapeHtml(candidate.normalized_text)}</p>` : ""}
|
|
<div class="editor-actions">
|
|
<button type="button" class="candidate-apply">Use This Candidate</button>
|
|
<button type="button" class="secondary-button candidate-add"${alreadyExists ? " disabled" : ""}>Add As Another Citation</button>
|
|
</div>
|
|
`;
|
|
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 = `<p class="error">${escapeHtml(data.error || "Unable to load document.")}</p>`;
|
|
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 = `<p class="error">${escapeHtml(data.error || "Unable to load citations.")}</p>`;
|
|
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 = `<p class="editor-status">${isEditorSession() ? "No species match the current archive filter." : "No species match the current search."}</p>`;
|
|
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 ? `<span class="species-state-badge">Archived</span>` : "";
|
|
const commonName = item.common_name || item.title;
|
|
const scientificName = item.scientific_name || "Scientific name missing";
|
|
button.innerHTML = `
|
|
<span class="species-name">${escapeHtml(commonName)}</span>
|
|
<span class="species-meta">Common name: ${escapeHtml(commonName)}</span>
|
|
<span class="species-meta">Scientific name: ${escapeHtml(scientificName)}</span>
|
|
<span class="species-meta">${escapeHtml(item.publication_status || "published")}${archivedMeta}</span>
|
|
<span class="species-meta">${item.diagnostic_count ? `${item.diagnostic_count} ingest flags` : "No ingest flags"}</span>
|
|
<span class="species-snippet">${escapeHtml((item.summary || "No summary extracted yet.").slice(0, 180))}</span>
|
|
`;
|
|
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 = `<p class="error">${escapeHtml(data.error || "Unable to load species.")}</p>`;
|
|
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 = `<p class="editor-status">No audit entries yet.</p>`;
|
|
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 `<li><strong>${escapeHtml(field)}</strong>: ${escapeHtml(String(values.from || ""))} -> ${escapeHtml(String(values.to || ""))}</li>`;
|
|
}
|
|
return `<li><strong>${escapeHtml(field)}</strong>: ${escapeHtml(String(values ?? ""))}</li>`;
|
|
})
|
|
.join("");
|
|
entry.innerHTML = `
|
|
<p class="audit-meta">${escapeHtml(item.changed_by)} • ${escapeHtml(item.changed_at)} • ${escapeHtml(item.action)}</p>
|
|
<ul class="diagnostic-list">${detailRows}</ul>
|
|
`;
|
|
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 = `<p class="error">${escapeHtml(data.error || "Unable to load audit history.")}</p>`;
|
|
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 = `<p class="error">Failed to load data: ${escapeHtml(String(error))}</p>`;
|
|
});
|
|
|
|
window.addEventListener("hashchange", async () => {
|
|
const slug = getInitialSpeciesSlug();
|
|
if (slug && slug !== currentSlug) {
|
|
await loadSpecies(slug);
|
|
}
|
|
});
|