const STATE = { q: "", page: 1, pageSize: 20, exact: true, previewHighlight: true, fulltext: true, sort: "relevance", sources: [], authors: [], tags: [], excludeSources: [], excludeAuthors: [], excludeTags: [], archiveId: "", publicationType: "", dateFromYear: "", dateFromMonth: "", dateFromDay: "", dateToYear: "", dateToMonth: "", dateToDay: "", dateTerms: [{ year: "", month: "", day: "" }], total: 0, results: [], facets: { sources: [], authors: [], tags: [], archives: [], types: [], years: [] }, facetTotals: { source: 0, author: 0, tag: 0 }, facetHasMore: { source: false, author: false, tag: false }, leftOpen: true, preview: "", compare: "", proofread: false, proofreadFile: "", proofreadPage: "", parseMode: false, parsePage: 1, parsePageSize: 100, parseTotal: 0, parseItems: [], parseItem: null, parseCurrentPage: 1, isDark: true, isMobile: false, filterSearch: { source: "", author: "", tag: "" }, filterPage: { source: 1, author: 1, tag: 1 }, filterCollapsed: {}, sidebarExpanded: false, }; const PERIOD_PRESETS = [ { label: "新民主主义革命时期", from: "19190504", to: "19490930" }, { label: "社会主义革命时期", from: "19491001", to: "19561231" }, { label: "全面建设社会主义时期", from: "19560915", to: "19660515" }, { label: "无产阶级文化大革命时期", from: "19660516", to: "19761006" }, { label: "改开-南巡", from: "19781218", to: "19920117" }, { label: "南巡-2012", from: "19920118", to: "20121114" }, { label: "2012-", from: "20121115", to: "" }, ]; const TAG_TYPES = ["文稿大类", "文稿类型", "地点", "人物", "出版方/发行方", "主题/事件", "记录", "审核", "翻译", "翻印/传抄"]; const PART_TYPES = ["appellation", "title", "authors", "place", "subtitle", "subtitle2", "subtitle3", "subtitle4", "subtitle5", "subdate", "paragraph", "quotation", "signature", "image", "image_description"]; const DOM = {}; const $ = (selector) => document.querySelector(selector); const THEME_ICON = { dark: '', light: '', }; let searchTimer = null; let facetRetryTimer = null; let progressTimer = null; let searchRequestSeq = 0; let searchAbortController = null; let searchPrefetchAbortController = null; let searchPrefetchCacheKey = ""; let bootstrapPending = false; let pdfjsPromise = null; let diffMatchPatchPromise = null; const DIFF_MATCH_PATCH_URL = "https://cdnjs.cloudflare.com/ajax/libs/diff_match_patch/20121119/diff_match_patch.js"; const DIFF_MATCH_PATCH_INTEGRITY = "sha512-5YZxUisfaneUbwv58nPp10qwt6DefHuJ+iAfvPoABZAYwLg4WGJHITaLpyyxJdrljfc0ggUoWc87Z0lfoDS84Q=="; const pdfDocumentCache = new Map(); const PDF_DOCUMENT_CACHE_LIMIT = 8; const PDF_DOCUMENT_MOBILE_CACHE_LIMIT = 3; const PDF_DOCUMENT_CACHE_IDLE_MS = 15 * 60 * 1000; let pdfCacheCleanupTimer = 0; let activePDFViewer = null; let activeImageResizeObserver = null; let activeImageFitFrame = 0; let parseDraftTimer = 0; let proofreadDraftTimer = 0; const prefetchedPageImages = new Set(); let cancelPageImagePrefetch = null; let pageImagePrefetchController = null; const searchCache = new Map(); const SEARCH_CACHE_LIMIT = 60; const SEARCH_CACHE_TTL = 8 * 60 * 1000; const REQUEST_TIMEOUT_MS = 12000; const KEEPALIVE_INTERVAL_MS = 45 * 1000; const KEEPALIVE_MIN_GAP_MS = 30 * 1000; const facetPending = new Map(); const facetRetryCounts = new Map(); const facetInputTimers = { source: null, author: null, tag: null }; const facetRequestSeq = { source: 0, author: 0, tag: 0 }; const facetAbortControllers = { source: null, author: null, tag: null }; let sourcesAbortController = null; const previewCache = new Map(); const parseOCRCache = new Map(); const PARSE_OCR_CACHE_LIMIT = 64; const PREVIEW_CACHE_LIMIT = 20; const PREVIEW_CACHE_MAX_CHARS = 2 * 1024 * 1024; const PREVIEW_CACHE_TTL = 10 * 60 * 1000; let previewCacheChars = 0; let pageRecoveryTimer = null; let previewRequestSeq = 0; let previewAbortController = null; let compareRequestSeq = 0; let compareAbortController = null; let randomRequestSeq = 0; let randomAbortController = null; let previewCloseUrl = "/"; let previewResultsScrollTop = 0; let animateNextPreview = false; let previewAnimationCleanup = null; let parseCropDrag = null; let activeYearInput = null; let yearPickerPointer = null; let searchComposing = false; let keepalivePending = null; let lastKeepaliveAt = Date.now(); function escapeHTML(value) { return String(value == null ? "" : value).replace(/[&<>"']/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[ch]); } function showToast(message) { DOM.toast.textContent = message; DOM.toast.hidden = false; DOM.toast.style.display = ""; clearTimeout(showToast.timer); showToast.timer = setTimeout(() => { DOM.toast.hidden = true; DOM.toast.style.display = "none"; }, 2400); } function motionAllowed() { return !window.matchMedia("(prefers-reduced-motion: reduce)").matches; } function parseUrl() { const params = new URLSearchParams(location.search); STATE.q = params.get("q") || ""; STATE.page = Math.max(1, parseInt(params.get("page") || "1", 10) || 1); const requestedPageSize = parseInt(params.get("page_size") || "20", 10); STATE.pageSize = [10, 20, 50].includes(requestedPageSize) ? requestedPageSize : 20; STATE.exact = params.get("exact") !== "0"; STATE.previewHighlight = params.get("preview_highlight") !== "0"; STATE.fulltext = params.get("fulltext") !== "0"; STATE.sort = params.get("sort") || "relevance"; STATE.sources = params.getAll("source"); STATE.authors = params.getAll("author"); STATE.tags = params.getAll("tag"); STATE.excludeSources = params.getAll("exclude_source"); STATE.excludeAuthors = params.getAll("exclude_author"); STATE.excludeTags = params.getAll("exclude_tag"); STATE.archiveId = params.get("archive") || ""; STATE.publicationType = params.get("type") || ""; setDateParts("from", params.get("date_from") || ""); setDateParts("to", params.get("date_to") || ""); if (!params.has("date_from")) setDateRangeParts("from", params.get("date_range_from") || ""); if (!params.has("date_to")) setDateRangeParts("to", params.get("date_range_to") || ""); const dateTerms = params.getAll("date_term").map(parseDateTermParam).filter(Boolean); STATE.dateTerms = uniqDateTerms(dateTerms); if (!STATE.dateTerms.length) STATE.dateTerms = [{ year: "", month: "", day: "" }]; STATE.leftOpen = params.get("sidebar") !== "0"; STATE.sidebarExpanded = params.get("sidebar_wide") === "1"; if (!STATE.leftOpen) STATE.sidebarExpanded = false; STATE.filterPage.source = Math.max(1, parseInt(params.get("source_page") || "1", 10) || 1); STATE.filterPage.author = Math.max(1, parseInt(params.get("author_page") || "1", 10) || 1); STATE.filterPage.tag = Math.max(1, parseInt(params.get("tag_page") || "1", 10) || 1); STATE.preview = params.get("preview") || ""; STATE.compare = params.get("compare") || ""; STATE.proofread = params.get("proofread") === "1"; STATE.proofreadFile = params.get("source_file") || ""; STATE.proofreadPage = params.get("proofread_page") || ""; STATE.parseMode = params.get("parse") === "1"; STATE.parsePage = Math.max(1, parseInt(params.get("parse_page") || "1", 10) || 1); const requestedParsePageSize = parseInt(params.get("parse_page_size") || "100", 10); STATE.parsePageSize = [10, 20, 50, 100].includes(requestedParsePageSize) ? requestedParsePageSize : 100; if (STATE.parseMode) { STATE.leftOpen = !STATE.isMobile; STATE.sidebarExpanded = false; } } function syncUrl(replace = true) { if (STATE.parseMode) { const params = new URLSearchParams(); params.set("parse", "1"); if (STATE.parsePage > 1) params.set("parse_page", String(STATE.parsePage)); if (STATE.parsePageSize !== 100) params.set("parse_page_size", String(STATE.parsePageSize)); history[replace ? "replaceState" : "pushState"]({}, "", `/?${params.toString()}`); return; } const params = new URLSearchParams(); if (STATE.q) params.set("q", STATE.q); if (STATE.page > 1) params.set("page", String(STATE.page)); if (STATE.pageSize !== 20) params.set("page_size", String(STATE.pageSize)); if (!STATE.exact) params.set("exact", "0"); if (!STATE.previewHighlight) params.set("preview_highlight", "0"); if (!STATE.fulltext) params.set("fulltext", "0"); if (STATE.sort !== "relevance") params.set("sort", STATE.sort); STATE.sources.forEach((source) => params.append("source", source)); STATE.authors.forEach((author) => params.append("author", author)); STATE.tags.forEach((tag) => params.append("tag", tag)); STATE.excludeSources.forEach((source) => params.append("exclude_source", source)); STATE.excludeAuthors.forEach((author) => params.append("exclude_author", author)); STATE.excludeTags.forEach((tag) => params.append("exclude_tag", tag)); if (STATE.archiveId) params.set("archive", STATE.archiveId); if (STATE.publicationType) params.set("type", STATE.publicationType); const dateFrom = dateValue("from"); const dateTo = dateValue("to"); if (dateFrom) params.set("date_from", String(dateFrom)); if (dateTo) params.set("date_to", String(dateTo)); if (!dateFrom && dateRangeParam("from")) params.set("date_range_from", dateRangeParam("from")); if (!dateTo && dateRangeParam("to")) params.set("date_range_to", dateRangeParam("to")); uniqDateTerms(STATE.dateTerms).map(dateTermParam).filter(Boolean).forEach((term) => params.append("date_term", term)); if (!STATE.leftOpen) params.set("sidebar", "0"); if (STATE.sidebarExpanded) params.set("sidebar_wide", "1"); if (STATE.filterPage.source > 1) params.set("source_page", String(STATE.filterPage.source)); if (STATE.filterPage.author > 1) params.set("author_page", String(STATE.filterPage.author)); if (STATE.filterPage.tag > 1) params.set("tag_page", String(STATE.filterPage.tag)); if (STATE.preview) params.set("preview", STATE.preview); if (STATE.compare) params.set("compare", STATE.compare); if (STATE.proofread) params.set("proofread", "1"); if (STATE.proofreadFile) params.set("source_file", STATE.proofreadFile); if (STATE.proofreadPage) params.set("proofread_page", STATE.proofreadPage); const url = params.toString() ? `/?${params.toString()}` : "/"; if (STATE.preview) { const closeParams = new URLSearchParams(params); closeParams.delete("preview"); previewCloseUrl = closeParams.toString() ? `/?${closeParams.toString()}` : "/"; } const previewEntry = Boolean(STATE.preview && (!replace || (history.state && history.state.previewEntry === true))); history[replace ? "replaceState" : "pushState"]({ previewEntry }, "", url); } function applyStateToDom() { document.body.classList.toggle("light", !STATE.isDark); document.body.classList.toggle("mobile", STATE.isMobile); document.body.classList.toggle("force-desktop", !STATE.isMobile); document.body.classList.toggle("preview-open", Boolean(STATE.preview)); document.body.classList.toggle("parse-mode", STATE.parseMode); if (DOM.parseBtn) DOM.parseBtn.classList.toggle("primary", STATE.parseMode); if (DOM.sidebarTitle) DOM.sidebarTitle.textContent = STATE.parseMode ? "解析队列" : "搜索过滤"; if (DOM.parsePageSizeOption) DOM.parsePageSizeOption.hidden = !STATE.parseMode; DOM.themeBtn.innerHTML = THEME_ICON[STATE.isDark ? "dark" : "light"]; DOM.themeBtn.setAttribute("aria-label", STATE.isDark ? "切换到白天模式" : "切换到黑夜模式"); DOM.themeBtn.title = STATE.isDark ? "白天模式" : "黑夜模式"; if (DOM.mobileToggleIcon) DOM.mobileToggleIcon.className = STATE.isMobile ? "ui-icon ui-icon-phone" : "ui-icon ui-icon-desktop"; DOM.searchInput.value = STATE.q; DOM.exactToggle.checked = STATE.exact; DOM.previewHighlightToggle.checked = STATE.previewHighlight; DOM.fulltextToggle.checked = STATE.fulltext; DOM.sortFilter.value = STATE.sort; DOM.pageSize.value = String(STATE.parseMode ? STATE.parsePageSize : STATE.pageSize); DOM.archiveFilter.value = STATE.archiveId; DOM.typeFilter.value = STATE.publicationType; applyDateStateToDom(); DOM.clearFilters.hidden = !hasActiveFilters(); DOM.leftSidebar.classList.toggle("collapsed", !STATE.leftOpen); DOM.leftSidebar.classList.toggle("open", STATE.leftOpen); DOM.leftSidebar.classList.toggle("expanded-wide", STATE.leftOpen && STATE.sidebarExpanded); const sidebarOverlayOpen = STATE.isMobile && STATE.leftOpen; DOM.overlay.style.display = ""; DOM.overlay.classList.toggle("open", sidebarOverlayOpen); for (const [key, collapsed] of Object.entries(STATE.filterCollapsed)) { const body = document.querySelector(`[data-filter-body="${key}"]`); const toggle = document.querySelector(`[data-filter-toggle="${key}"]`); if (body) body.classList.toggle("collapsed", collapsed); if (toggle) toggle.classList.toggle("collapsed", collapsed); } const previewThemeButton = DOM.previewPanel ? DOM.previewPanel.querySelector("[data-preview-theme]") : null; if (previewThemeButton) { previewThemeButton.setAttribute("aria-label", STATE.isDark ? "切换到白天模式" : "切换到黑夜模式"); previewThemeButton.title = STATE.isDark ? "白天模式" : "黑夜模式"; previewThemeButton.innerHTML = THEME_ICON[STATE.isDark ? "dark" : "light"]; } const previewMobileButton = DOM.previewPanel ? DOM.previewPanel.querySelector("[data-preview-mobile]") : null; if (previewMobileButton) { const mobileLabel = STATE.isMobile ? "切换到电脑模式" : "切换到手机模式"; previewMobileButton.setAttribute("aria-label", mobileLabel); previewMobileButton.title = mobileLabel; previewMobileButton.innerHTML = ``; } } function toggleTheme() { STATE.isDark = !STATE.isDark; localStorage.setItem("theme", STATE.isDark ? "dark" : "light"); applyStateToDom(); } function toggleMobileMode() { STATE.isMobile = !STATE.isMobile; localStorage.setItem("mobileMode", STATE.isMobile ? "mobile" : "desktop"); if (STATE.isMobile) { STATE.leftOpen = false; STATE.sidebarExpanded = false; } applyStateToDom(); relocateFulltextEditor(); trimPDFDocumentCache(); syncUrl(); } function relocateFulltextEditor() { if (!STATE.proofread) return; const form = DOM.previewPanel ? DOM.previewPanel.querySelector("[data-proofread-form]") : null; if (!form) return; const box = form.querySelector("[data-fulltext-box]"); const toggle = form.querySelector("[data-fulltext-toggle]"); if (!box || !toggle) return; const header = form.querySelector(".proofread-pane-header"); if (STATE.isMobile) { if (box.closest("[data-fulltext-section]")) return; const section = document.createElement("div"); section.className = "proofread-fulltext-section"; section.dataset.fulltextSection = "true"; const actions = document.createElement("div"); actions.className = "proofread-form-actions"; actions.append(toggle); section.append(actions, box); if (header && header.nextSibling) header.after(section); else form.prepend(section); } else { const section = box.closest("[data-fulltext-section]"); if (!section) return; const fieldset = Array.from(form.querySelectorAll("fieldset")).find((f) => f.querySelector("[data-part-editor]")) || form.querySelector("fieldset"); if (!fieldset) return; section.remove(); const actions = fieldset.querySelector(".proofread-form-actions"); const partEditor = fieldset.querySelector("[data-part-editor]"); if (actions) { const partToggle = fieldset.querySelector("[data-part-editor-toggle]"); actions.insertBefore(toggle, partToggle || null); if (partEditor) fieldset.insertBefore(box, partEditor); } else { fieldset.prepend(toggle); fieldset.insertBefore(box, toggle.nextSibling); } } } function hasActiveFilters() { return Boolean( STATE.sources.length || STATE.authors.length || STATE.tags.length || STATE.excludeSources.length || STATE.excludeAuthors.length || STATE.excludeTags.length || STATE.archiveId || STATE.publicationType || hasDateFilter() ); } function setDateParts(side, value) { const text = String(value || "").replace(/\D/g, ""); const prefix = side === "from" ? "dateFrom" : "dateTo"; STATE[`${prefix}Year`] = text.length >= 4 ? cleanYear(text.slice(0, 4)) : ""; STATE[`${prefix}Month`] = text.length >= 6 ? String(parseInt(text.slice(4, 6), 10)) : ""; STATE[`${prefix}Day`] = text.length >= 8 ? String(parseInt(text.slice(6, 8), 10)) : ""; } function cleanYear(value) { const text = String(value || "").replace(/\D/g, "").slice(0, 4); const number = parseInt(text, 10); return Number.isFinite(number) && number >= 1 && number <= 9999 ? String(number) : ""; } function setDateRangeParts(side, value) { const term = parseDateTermParam(value); const prefix = side === "from" ? "dateFrom" : "dateTo"; STATE[`${prefix}Year`] = term ? (term.year || "") : ""; STATE[`${prefix}Month`] = term ? (term.month || "") : ""; STATE[`${prefix}Day`] = term ? (term.day || "") : ""; } function hasDateFilter() { return Boolean( STATE.dateFromYear || STATE.dateFromMonth || STATE.dateFromDay || STATE.dateToYear || STATE.dateToMonth || STATE.dateToDay || STATE.dateTerms.some((term) => term.year || term.month || term.day) ); } function daysInMonth(year, month) { return new Date(Number(year), Number(month), 0).getDate(); } function padded(value) { return String(value).padStart(2, "0"); } function dateValue(side) { const prefix = side === "from" ? "dateFrom" : "dateTo"; const year = cleanYear(STATE[`${prefix}Year`]); if (!year || !STATE[`${prefix}Month`] || !STATE[`${prefix}Day`]) return null; const month = STATE[`${prefix}Month`]; const day = STATE[`${prefix}Day`]; return parseInt(`${year}${padded(month)}${padded(day)}`, 10); } function rangeDateValue(side) { const prefix = side === "from" ? "dateFrom" : "dateTo"; const year = cleanYear(STATE[`${prefix}Year`]); if (!year) return null; const month = STATE[`${prefix}Month`] || (side === "from" ? "1" : "12"); const day = STATE[`${prefix}Day`] || (side === "from" ? "1" : String(daysInMonth(year, month))); return parseInt(`${year}${padded(month)}${padded(day)}`, 10); } function dateKey(side) { const value = dateValue(side); return value ? String(value) : ""; } function validateDateRange({ show = true } = {}) { const from = rangeDateValue("from"); const to = rangeDateValue("to"); const invalid = Boolean(from && to && from > to); if (DOM.dateError) { DOM.dateError.hidden = !invalid; DOM.dateError.textContent = invalid ? "开始年份不能晚于结束年份" : ""; } if (invalid && show) showToast("开始年份不能晚于结束年份"); return !invalid; } function clearDateFilter() { setDateParts("from", ""); setDateParts("to", ""); STATE.dateTerms = [{ year: "", month: "", day: "" }]; } function yearItems() { const isFourDigitYear = (value) => /^\d{4}$/.test(String(value)); const years = (STATE.facets.years || []).map((item) => String(item.value == null ? item.name : item.value)).filter(isFourDigitYear); const selectedYears = [STATE.dateFromYear, STATE.dateToYear, ...STATE.dateTerms.map((term) => term.year)].filter(isFourDigitYear); const values = years.length ? years : Array.from({ length: 183 }, (_, index) => String(1843 + index)); return [...new Set([...values, ...selectedYears])].sort((a, b) => Number(a) - Number(b)); } function selectOptions(values, selected, emptyLabel) { const options = [``]; for (const value of values) { const text = String(value); options.push(``); } return options.join(""); } function dayOptions(year, month, selected) { const max = year && month ? daysInMonth(year, month) : 31; return selectOptions(Array.from({ length: max }, (_, index) => index + 1), selected, "日"); } function renderPeriodPresets() { const currentFrom = dateKey("from"); const currentTo = dateKey("to"); DOM.periodPresets.innerHTML = PERIOD_PRESETS.map((period) => { const active = currentFrom === period.from && currentTo === period.to; return ``; }).join(""); } function renderDateControls() { DOM.dateFromYear.value = STATE.dateFromYear; DOM.dateToYear.value = STATE.dateToYear; DOM.dateFromMonth.innerHTML = selectOptions(Array.from({ length: 12 }, (_, index) => index + 1), STATE.dateFromMonth, "月"); DOM.dateToMonth.innerHTML = selectOptions(Array.from({ length: 12 }, (_, index) => index + 1), STATE.dateToMonth, "月"); DOM.dateFromDay.innerHTML = dayOptions(STATE.dateFromYear, STATE.dateFromMonth, STATE.dateFromDay); DOM.dateToDay.innerHTML = dayOptions(STATE.dateToYear, STATE.dateToMonth, STATE.dateToDay); DOM.dateFromMonth.disabled = !STATE.dateFromYear; DOM.dateToMonth.disabled = !STATE.dateToYear; DOM.dateFromDay.disabled = !STATE.dateFromYear || !STATE.dateFromMonth; DOM.dateToDay.disabled = !STATE.dateToYear || !STATE.dateToMonth; DOM.dateClear.hidden = !hasDateFilter(); renderPeriodPresets(); renderDateTerms(); validateDateRange({ show: false }); } function parseDateTermParam(value) { const parts = String(value || "").split("-"); const term = { year: "", month: "", day: "", display: "" }; for (const part of parts) { const [kind, rawValue] = part.split(":"); if (kind === "display") { term.display = decodeURIComponent(rawValue || ""); continue; } if (!["year", "month", "day"].includes(kind)) continue; const number = parseInt(rawValue, 10); if (!Number.isFinite(number)) continue; if (kind === "year" && number >= 1 && number <= 9999) term.year = String(number); if (kind === "month" && number >= 1 && number <= 12) term.month = String(number); if (kind === "day" && number >= 1 && number <= 31) term.day = String(number); } return term.year || term.month || term.day || term.display ? term : null; } function dateTermParam(term) { const parts = []; const year = cleanYear(term.year); if (year) parts.push(`year:${year}`); if (term.month) parts.push(`month:${term.month}`); if (term.day) parts.push(`day:${term.day}`); if (term.display) parts.push(`display:${encodeURIComponent(term.display)}`); return parts.join("-"); } function normalizedDateTerm(term) { const monthNumber = term && term.month ? parseInt(term.month, 10) : null; const dayNumber = term && term.day ? parseInt(term.day, 10) : null; return { year: cleanYear(term && term.year) || "", month: monthNumber && monthNumber >= 1 && monthNumber <= 12 ? String(monthNumber) : "", day: dayNumber && dayNumber >= 1 && dayNumber <= 31 ? String(dayNumber) : "", display: typeof (term && term.display) === "string" ? term.display : "", }; } function dateTermKey(term) { const normalized = normalizedDateTerm(term); return normalized.display ? `display|${normalized.display}` : `${normalized.year}|${normalized.month}|${normalized.day}`; } function uniqDateTerms(terms) { const seen = new Set(); const result = []; for (const term of terms || []) { const normalized = normalizedDateTerm(term); if (!(normalized.year || normalized.month || normalized.day || normalized.display)) continue; const key = dateTermKey(normalized); if (seen.has(key)) continue; seen.add(key); result.push(normalized); } return result; } function parseDisplayDateTerm(value) { const parts = String(value || "").split("/").map((part) => part.trim()); let year = ""; let month = ""; let day = ""; if (parts.length >= 3) { year = parts[0] && parts[0] !== "----" ? cleanYear(parts[0]) : ""; const monthNumber = parts[1] && parts[1] !== "--" ? parseInt(parts[1], 10) : null; const dayNumber = parts[2] && parts[2] !== "--" ? parseInt(parts[2], 10) : null; month = monthNumber && monthNumber >= 1 && monthNumber <= 12 ? String(monthNumber) : ""; day = dayNumber && dayNumber >= 1 && dayNumber <= 31 ? String(dayNumber) : ""; } else { const numbers = String(value || "").match(/\d{1,4}/g) || []; if (numbers.length >= 2) { const first = parseInt(numbers[0], 10); const second = parseInt(numbers[1], 10); if (first >= 1 && first <= 12 && second >= 1 && second <= 31) { month = String(first); day = String(second); } else { year = cleanYear(numbers[0]); month = second >= 1 && second <= 12 ? String(second) : ""; } } else if (numbers.length === 1) { year = cleanYear(numbers[0]); } } return year || month || day ? { year, month, day } : null; } function resultDateTermKey(value) { const text = String(value || "").trim(); const parsed = parseDisplayDateTerm(text); return parsed ? dateTermKey(parsed) : (text ? `display|${text}` : ""); } function resultDateTermPayload(value) { const display = String(value || "").trim(); if (!display) return null; return parseDisplayDateTerm(display) || { year: null, month: null, day: null, display }; } function dateRangeParam(side) { const prefix = side === "from" ? "dateFrom" : "dateTo"; return dateTermParam({ year: STATE[`${prefix}Year`], month: STATE[`${prefix}Month`], day: STATE[`${prefix}Day`], }); } function renderDateTerms() { if (!DOM.dateTerms) return; DOM.dateTerms.innerHTML = STATE.dateTerms.map((term, index) => { const monthOptions = selectOptions(Array.from({ length: 12 }, (_, item) => item + 1), term.month || "", "月"); const dayOptionsHtml = selectOptions(Array.from({ length: 31 }, (_, item) => item + 1), term.day || "", "日"); return `
`; }).join(""); } function yearInputFromTarget(target) { const input = target && target.closest ? target.closest(".year-input") : null; return input instanceof HTMLInputElement ? input : null; } function showYearPicker(input) { activeYearInput = input; const keyword = input.value.trim(); const years = yearItems().filter((year) => !keyword || String(year).includes(keyword)); DOM.yearPicker.innerHTML = years.length ? years.map((year) => ``).join("") : '
无匹配年份
'; const rect = input.getBoundingClientRect(); const gap = 4; const viewportPadding = 8; const width = Math.max(rect.width, 92); const preferredHeight = Math.min(360, Math.floor(window.innerHeight * 0.52)); const spaceBelow = window.innerHeight - rect.bottom - gap - viewportPadding; const spaceAbove = rect.top - gap - viewportPadding; const openAbove = spaceBelow < Math.min(180, preferredHeight) && spaceAbove > spaceBelow; const availableHeight = Math.max(80, Math.min(preferredHeight, openAbove ? spaceAbove : spaceBelow)); const left = Math.max(viewportPadding, Math.min(rect.left, window.innerWidth - width - viewportPadding)); DOM.yearPicker.style.left = `${left}px`; DOM.yearPicker.style.width = `${width}px`; DOM.yearPicker.style.maxHeight = `${availableHeight}px`; DOM.yearPicker.style.top = openAbove ? "auto" : `${rect.bottom + gap}px`; DOM.yearPicker.style.bottom = openAbove ? `${window.innerHeight - rect.top + gap}px` : "auto"; DOM.yearPicker.hidden = false; } function repositionYearPicker() { if (activeYearInput && !DOM.yearPicker.hidden) showYearPicker(activeYearInput); } function hideYearPicker() { DOM.yearPicker.hidden = true; activeYearInput = null; } function handleYearPickerScroll(event) { if (event.target === DOM.yearPicker || DOM.yearPicker.contains(event.target)) return; if (activeYearInput && document.activeElement === activeYearInput) { repositionYearPicker(); return; } hideYearPicker(); } function chooseYear(input, year) { input.value = year; input.dispatchEvent(new Event("change", { bubbles: true })); hideYearPicker(); } function applyDateStateToDom() { if (DOM.dateFromYear) renderDateControls(); } function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function queryTerms() { const q = STATE.q.trim(); return q.split(/\s+/).map((item) => item.trim()).filter(Boolean); } function highlightPreviewText(text) { let html = escapeHTML(text); if (!STATE.previewHighlight || !STATE.q.trim()) return html; if (STATE.exact) { const pattern = new RegExp(escapeRegExp(escapeHTML(STATE.q.trim())), "g"); return html.replace(pattern, (match) => match ? `${match}` : match); } const terms = [...new Set(queryTerms().map(escapeHTML).filter(Boolean))].sort((a, b) => b.length - a.length); if (!terms.length) return html; const pattern = new RegExp(terms.map(escapeRegExp).join("|"), "g"); return html.replace(pattern, (match) => `${match}`); } function cancelPreviewRequests() { previewRequestSeq += 1; compareRequestSeq += 1; randomRequestSeq += 1; if (previewAbortController) previewAbortController.abort(); if (compareAbortController) compareAbortController.abort(); if (randomAbortController) randomAbortController.abort(); previewAbortController = null; compareAbortController = null; randomAbortController = null; } function clearPreviewDom() { flushProofreadDraft(); clearActiveMediaViewer(); stopPreviewAnimation(); document.body.classList.remove("preview-open"); DOM.previewPanel.hidden = true; DOM.previewPanel.style.display = "none"; DOM.previewPanel.innerHTML = ""; DOM.previewPanel.classList.remove("proofread-mode"); animateNextPreview = false; } function closePreview(useHistory = false) { flushProofreadDraft(); cancelPreviewRequests(); if (useHistory && history.state && history.state.previewEntry === true) { history.back(); return; } STATE.preview = ""; STATE.compare = ""; STATE.proofread = false; STATE.proofreadItem = null; STATE.proofreadFile = ""; STATE.proofreadPage = ""; clearPreviewDom(); renderResults(); syncUrl(); } function restoreResultsScroll(top) { requestAnimationFrame(() => { DOM.results.scrollTop = top; requestAnimationFrame(() => { DOM.results.scrollTop = top; }); }); } async function api(path, options) { const response = await fetch(path, options); if (!response.ok) { let detail = ""; try { const body = await response.json(); if (body && body.error) detail = `:${body.error}`; } catch (_error) { // keep the status-only message } throw new Error(`HTTP ${response.status}${detail}`); } return response.json(); } async function apiWithTimeout(path, options = {}, timeoutMs = REQUEST_TIMEOUT_MS) { const controller = new AbortController(); const externalSignal = options.signal; const abortFromExternal = () => controller.abort(); if (externalSignal) { if (externalSignal.aborted) controller.abort(); else externalSignal.addEventListener("abort", abortFromExternal, { once: true }); } const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const nextOptions = Object.assign({}, options, { signal: controller.signal }); try { return await api(path, nextOptions); } finally { clearTimeout(timeoutId); if (externalSignal) externalSignal.removeEventListener("abort", abortFromExternal); } } async function healthWithTimeout(timeoutMs = REQUEST_TIMEOUT_MS) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch("/api/health", { signal: controller.signal }); return await response.json(); } finally { clearTimeout(timeoutId); } } function warmConnection(force = false) { if (document.hidden || !navigator.onLine || keepalivePending) return keepalivePending; const now = Date.now(); if (!force && now - lastKeepaliveAt < KEEPALIVE_MIN_GAP_MS) return null; lastKeepaliveAt = now; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); keepalivePending = fetch("/api/ping", { cache: "no-store", signal: controller.signal, }).catch(() => null).finally(() => { clearTimeout(timeoutId); keepalivePending = null; }); return keepalivePending; } function stableStringify(value) { if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; if (value && typeof value === "object") { return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`; } return JSON.stringify(value); } function getCachedSearch(key) { if (!searchCache.has(key)) return null; const entry = searchCache.get(key); if (!entry || Date.now() - entry.time > SEARCH_CACHE_TTL) { searchCache.delete(key); return null; } searchCache.delete(key); searchCache.set(key, entry); return entry.value; } function setCachedSearch(key, value) { searchCache.set(key, { value, time: Date.now() }); while (searchCache.size > SEARCH_CACHE_LIMIT) { searchCache.delete(searchCache.keys().next().value); } } function getCachedPreview(docId) { if (!previewCache.has(docId)) return null; const entry = previewCache.get(docId); if (!entry || Date.now() - entry.time > PREVIEW_CACHE_TTL) { if (entry) previewCacheChars -= String(entry.value && entry.value.content ? entry.value.content : "").length; previewCache.delete(docId); return null; } previewCache.delete(docId); previewCache.set(docId, entry); return entry.value; } function setCachedPreview(docId, value) { const size = String(value && value.content ? value.content : "").length; if (size > 500000) return; if (previewCache.has(docId)) { const previous = previewCache.get(docId); previewCacheChars -= String(previous.value && previous.value.content ? previous.value.content : "").length; } previewCache.set(docId, { value, time: Date.now() }); previewCacheChars += size; while (previewCache.size > PREVIEW_CACHE_LIMIT || previewCacheChars > PREVIEW_CACHE_MAX_CHARS) { const firstKey = previewCache.keys().next().value; const firstValue = previewCache.get(firstKey); previewCacheChars -= String(firstValue.value && firstValue.value.content ? firstValue.value.content : "").length; previewCache.delete(firstKey); } } function searchPayload() { const dateFrom = rangeDateValue("from"); const dateTo = rangeDateValue("to"); return { q: STATE.q, page: STATE.page, page_size: STATE.pageSize, exact: STATE.exact, fulltext: STATE.fulltext, sort: STATE.sort, sources: STATE.sources, authors: STATE.authors, tags: STATE.tags, exclude_sources: STATE.excludeSources, exclude_authors: STATE.excludeAuthors, exclude_tags: STATE.excludeTags, archive_id: STATE.archiveId ? parseInt(STATE.archiveId, 10) : null, publication_type: STATE.publicationType || null, date_from: dateFrom, date_to: dateTo, date_terms: STATE.dateTerms .filter((term) => term.year || term.month || term.day || term.display) .map((term) => ({ year: term.year ? parseInt(term.year, 10) : null, month: term.month ? parseInt(term.month, 10) : null, day: term.day ? parseInt(term.day, 10) : null, display: term.display || null, })), }; } function abortSearchPrefetch() { if (searchPrefetchAbortController) searchPrefetchAbortController.abort(); searchPrefetchAbortController = null; searchPrefetchCacheKey = ""; } function prefetchNextSearchPage(basePayload) { if (!STATE.total || STATE.page >= Math.ceil(STATE.total / STATE.pageSize)) return; const payload = Object.assign({}, basePayload || searchPayload(), { page: STATE.page + 1 }); const cacheKey = stableStringify(payload); if (getCachedSearch(cacheKey)) return; if (searchPrefetchAbortController && searchPrefetchCacheKey === cacheKey) return; abortSearchPrefetch(); const controller = new AbortController(); searchPrefetchAbortController = controller; searchPrefetchCacheKey = cacheKey; apiWithTimeout("/api/search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), signal: controller.signal, }, 25000) .then((data) => { if (!controller.signal.aborted && data.index_status === "ready" && !data.indexing) setCachedSearch(cacheKey, data); }) .catch((error) => { if (error.name !== "AbortError") console.warn("搜索预取失败", error); }) .finally(() => { if (searchPrefetchAbortController === controller) { searchPrefetchAbortController = null; searchPrefetchCacheKey = ""; } }); } function scheduleNextSearchPagePrefetch(basePayload) { const cacheKey = stableStringify(basePayload); const run = () => { if (cacheKey === stableStringify(searchPayload())) prefetchNextSearchPage(basePayload); }; if ("requestIdleCallback" in window) window.requestIdleCallback(run, { timeout: 1500 }); else window.setTimeout(run, 0); } function canUseInitialBootstrap() { const payload = searchPayload(); return payload.q === "" && payload.page === 1 && payload.page_size === 20 && payload.exact === true && payload.fulltext === true && payload.sort === "relevance" && !payload.source && !payload.author && !payload.tag && !payload.archive_id && !payload.publication_type && !payload.date_from && !payload.date_to && !payload.sources.length && !payload.authors.length && !payload.tags.length && !payload.exclude_sources.length && !payload.exclude_authors.length && !payload.exclude_tags.length && !payload.date_terms.length; } async function loadInitialBootstrap() { bootstrapPending = true; DOM.statusText.textContent = "搜索中..."; DOM.emptyState.style.display = "none"; try { const earlyBootstrap = window.__bhaBootstrapPromise; delete window.__bhaBootstrapPromise; const data = earlyBootstrap ? await earlyBootstrap : await apiWithTimeout("/api/bootstrap", {}, 25000); if (!data) throw new Error("bootstrap is unavailable"); const search = data.search || {}; if (search.index_status !== "ready" || search.indexing) throw new Error("bootstrap is not ready"); STATE.results = search.results || []; STATE.total = Number(search.total || 0); STATE.pageSize = Number(search.page_size || 20); const sources = data.sources || {}; const facets = data.facets || {}; STATE.facets = { sources: sortFacetItems((facets.source || {}).items || []), authors: sortFacetItems((facets.author || {}).items || []), tags: sortFacetItems((facets.tag || {}).items || []), archives: sortFacetItems(sources.archives || []), types: sortFacetItems(sources.types || []), years: sortFacetItems(sources.years || []), }; for (const kind of ["source", "author", "tag"]) { STATE.facetTotals[kind] = Number((facets[kind] || {}).total || 0); STATE.facetHasMore[kind] = Boolean((facets[kind] || {}).has_more); } setCachedSearch(stableStringify(searchPayload()), search); renderFilterOptions({ preserveScroll: false }); renderResults({ animate: true }); DOM.results.scrollTop = 0; DOM.statusText.textContent = ""; DOM.indexProgress.textContent = ""; scheduleNextSearchPagePrefetch(searchPayload()); } catch (_error) { await Promise.all([doSearch(), loadFacets()]); } finally { bootstrapPending = false; } } async function loadFacets() { const pendingKey = "sources"; if (facetPending.has(pendingKey)) return facetPending.get(pendingKey); const pending = loadFacetsNow().finally(() => facetPending.delete(pendingKey)); facetPending.set(pendingKey, pending); return pending; } async function loadFacetsNow() { if (sourcesAbortController) sourcesAbortController.abort(); sourcesAbortController = new AbortController(); const controller = sourcesAbortController; try { const data = await apiWithTimeout("/api/sources", { signal: controller.signal }); if (Array.isArray(data)) { STATE.facets.sources = sortFacetItems(data); } else { STATE.facets = { sources: STATE.facets.sources, authors: STATE.facets.authors, tags: STATE.facets.tags, archives: sortFacetItems(data.archives || []), types: sortFacetItems(data.types || []), years: sortFacetItems(data.years || []), }; } renderFilterOptions({ preserveScroll: false }); facetRetryCounts.delete("sources"); loadFacetPage("source"); loadFacetPage("author"); loadFacetPage("tag"); clearTimeout(facetRetryTimer); if (data.index_status === "failed") { DOM.statusText.textContent = "索引失败,请看日志"; return; } if (data.indexing) { if (!STATE.facets.sources.length && !STATE.facets.authors.length && !STATE.facets.tags.length) { DOM.sourceFilter.innerHTML = ''; } clearTimeout(facetRetryTimer); facetRetryTimer = setTimeout(loadFacets, 5000); } } catch (error) { const tries = facetRetryCounts.get("sources") || 0; if (tries < 2) { facetRetryCounts.set("sources", tries + 1); DOM.sourceFilter.innerHTML = ''; setTimeout(loadFacets, 1200); } else { DOM.sourceFilter.innerHTML = ''; } } finally { if (sourcesAbortController === controller) sourcesAbortController = null; } } async function loadFacetPage(kind, { preserveScroll = false } = {}) { const page = STATE.filterPage[kind]; const keyword = STATE.filterSearch[kind] || ""; const key = kind === "source" ? "sources" : kind === "author" ? "authors" : "tags"; const pendingKey = `${kind}|${page}|${keyword}`; if (facetPending.has(pendingKey)) return facetPending.get(pendingKey); const requestSeq = (facetRequestSeq[kind] || 0) + 1; facetRequestSeq[kind] = requestSeq; let data; let controller = null; const pending = (async () => { try { if (facetAbortControllers[kind]) facetAbortControllers[kind].abort(); controller = new AbortController(); facetAbortControllers[kind] = controller; data = await apiWithTimeout(`/api/facet/${kind}?page=${encodeURIComponent(page)}&page_size=200&q=${encodeURIComponent(keyword)}`, { signal: controller.signal }); } catch (error) { if (requestSeq !== facetRequestSeq[kind]) return; const tries = facetRetryCounts.get(pendingKey) || 0; if (tries < 2) { facetRetryCounts.set(pendingKey, tries + 1); const filter = kind === "source" ? DOM.sourceFilter : kind === "author" ? DOM.authorFilter : DOM.tagFilter; filter.innerHTML = ''; setTimeout(() => loadFacetPage(kind, { preserveScroll }), 1200); } else if (!STATE.facets[key].length) { const filter = kind === "source" ? DOM.sourceFilter : kind === "author" ? DOM.authorFilter : DOM.tagFilter; filter.innerHTML = ''; } return; } if (requestSeq !== facetRequestSeq[kind]) return; facetRetryCounts.delete(pendingKey); STATE.facets[key] = sortFacetItems(data.items || []); STATE.facetTotals[kind] = Number(data.total || 0); STATE.facetHasMore[kind] = Boolean(data.has_more); renderFacetFilter(kind, { preserveScroll }); })().finally(() => { facetPending.delete(pendingKey); if (facetAbortControllers[kind] === controller) facetAbortControllers[kind] = null; }); facetPending.set(pendingKey, pending); return pending; } function sortFacetItems(items) { return [...items].sort((a, b) => String(a.name == null ? (a.value == null ? "" : a.value) : a.name).localeCompare(String(b.name == null ? (b.value == null ? "" : b.value) : b.name), "zh-Hans-CN", { numeric: true, sensitivity: "base" })); } function optionHTML(items, selected, includeEmpty = true) { const options = includeEmpty ? [''] : []; for (const item of items) { const value = String(item.value == null ? item.name : item.value); options.push(``); } return options.join(""); } function filterScrollPositions() { return { source: (DOM.sourceFilter.querySelector(".filter-choice-options") || { scrollTop: 0 }).scrollTop || 0, author: (DOM.authorFilter.querySelector(".filter-choice-options") || { scrollTop: 0 }).scrollTop || 0, tag: (DOM.tagFilter.querySelector(".filter-choice-options") || { scrollTop: 0 }).scrollTop || 0, }; } function restoreFilterScrollPositions(positions) { for (const [kind, top] of Object.entries(positions)) { const filter = kind === "source" ? DOM.sourceFilter : kind === "author" ? DOM.authorFilter : DOM.tagFilter; const options = filter.querySelector(".filter-choice-options"); if (options) options.scrollTop = top; } } function renderFilterOptions({ preserveScroll = true } = {}) { const scrollPositions = preserveScroll ? filterScrollPositions() : null; DOM.sourceFilter.innerHTML = choiceListHTML(STATE.facets.sources, STATE.sources, STATE.excludeSources, "source", 200, STATE.filterPage.source, STATE.facetTotals.source, STATE.facetHasMore.source); DOM.authorFilter.innerHTML = choiceListHTML(STATE.facets.authors, STATE.authors, STATE.excludeAuthors, "author", 200, STATE.filterPage.author, STATE.facetTotals.author, STATE.facetHasMore.author); DOM.tagFilter.innerHTML = choiceListHTML(STATE.facets.tags, STATE.tags, STATE.excludeTags, "tag", 200, STATE.filterPage.tag, STATE.facetTotals.tag, STATE.facetHasMore.tag); DOM.archiveFilter.innerHTML = optionHTML(STATE.facets.archives, STATE.archiveId); DOM.typeFilter.innerHTML = optionHTML(STATE.facets.types, STATE.publicationType); renderDateControls(); if (scrollPositions) restoreFilterScrollPositions(scrollPositions); } function renderFacetFilter(kind, { preserveScroll = true } = {}) { const dataKey = kind === "source" ? "sources" : kind === "author" ? "authors" : "tags"; const includeKey = dataKey; const excludeKey = kind === "source" ? "excludeSources" : kind === "author" ? "excludeAuthors" : "excludeTags"; const filter = kind === "source" ? DOM.sourceFilter : kind === "author" ? DOM.authorFilter : DOM.tagFilter; const scrollTopNode = filter.querySelector(".filter-choice-options"); const scrollTop = preserveScroll ? (scrollTopNode ? scrollTopNode.scrollTop : 0) : 0; filter.innerHTML = choiceListHTML(STATE.facets[dataKey], STATE[includeKey], STATE[excludeKey], kind, 200, STATE.filterPage[kind], STATE.facetTotals[kind], STATE.facetHasMore[kind]); if (preserveScroll) { const options = filter.querySelector(".filter-choice-options"); if (options) options.scrollTop = scrollTop; } } function choiceListHTML(items, selectedValues, excludedValues, kind, limit = 80, page = 1, remoteTotal = null, hasMore = false) { const selectedSet = new Set(selectedValues); const excludedSet = new Set(excludedValues); const usableItems = items.filter((item) => String(item.name == null ? "" : item.name).trim()); const itemByName = new Map(usableItems.map((item) => [String(item.name == null ? "" : item.name), item])); const pinnedNames = [...new Set([...selectedValues, ...excludedValues].map((value) => String(value || "")).filter(Boolean))]; const pinnedNameSet = new Set(pinnedNames); const pinnedItems = pinnedNames.map((name) => itemByName.get(name) || { name }); const visibleItems = usableItems.filter((item) => !pinnedNameSet.has(String(item.name == null ? "" : item.name))); const total = remoteTotal == null ? usableItems.length : remoteTotal; const totalPages = remoteTotal === null ? null : (limit ? Math.max(1, Math.ceil(total / limit)) : 1); const safePage = totalPages ? Math.min(Math.max(1, page), totalPages) : Math.max(1, page); if (kind in STATE.filterPage && STATE.filterPage[kind] !== safePage) STATE.filterPage[kind] = safePage; const pageNames = usableItems.map((item) => String(item.name == null ? "" : item.name)).filter(Boolean); const allPageSelected = Boolean(pageNames.length) && pageNames.every((name) => selectedSet.has(name)); const allPageExcluded = Boolean(pageNames.length) && pageNames.every((name) => excludedSet.has(name)); const hasState = Boolean(selectedValues.length || excludedValues.length); let optionsHTML = `
全选本页${hasState ? `` : ""}
`; for (const item of [...pinnedItems, ...visibleItems]) { const name = String(item.name); const checked = selectedSet.has(name); const excluded = excludedSet.has(name); const countHTML = item.count == null ? '' : `${Number(item.count || 0).toLocaleString()}`; optionsHTML += `
${escapeHTML(name)}${countHTML}
`; } let html = `
${optionsHTML}
`; if (limit && (totalPages === null || total > limit || safePage > 1)) { html += `
/ ${totalPages == null ? "?" : totalPages}
`; } return html; } function pageFromInput(input, fallback, maxPage = null) { const raw = parseInt(input.value, 10); let page = Number.isFinite(raw) ? raw : fallback; page = Math.max(1, page); if (maxPage) page = Math.min(page, maxPage); input.value = String(page); return page; } function commitResultPage() { const maxPage = Math.max(1, Math.ceil(STATE.total / STATE.pageSize)); const nextPage = pageFromInput(DOM.pageInput, STATE.page, maxPage); if (nextPage === STATE.page) { DOM.pageInput.value = String(STATE.page); return; } STATE.page = nextPage; doSearch(); } function commitFilterPage(kind, input) { const total = STATE.facetTotals[kind]; const maxPage = total ? Math.max(1, Math.ceil(total / 200)) : null; const nextPage = pageFromInput(input, STATE.filterPage[kind], maxPage); if (nextPage === STATE.filterPage[kind]) { input.value = String(STATE.filterPage[kind]); return; } STATE.filterPage[kind] = nextPage; loadFacetPage(kind); syncUrl(); } function restoreFilterPageInput(kind, input) { input.value = String(STATE.filterPage[kind]); } function handleFilterPageButton(kind, container, pageButton) { if (pageButton.disabled) return; const input = container.querySelector(`[data-${kind}-page-input]`); if (input) restoreFilterPageInput(kind, input); STATE.filterPage[kind] += pageButton.dataset[`${kind}Page`] === "next" ? 1 : -1; loadFacetPage(kind); syncUrl(); } function scheduleFacetSearch(kind) { clearTimeout(facetInputTimers[kind]); facetInputTimers[kind] = setTimeout(() => { loadFacetPage(kind, { preserveScroll: false }); }, 150); } function recoverPageState() { if (bootstrapPending) return; if (DOM.statusText.textContent === "搜索中..." && !searchAbortController) DOM.statusText.textContent = ""; let shouldLoadSources = false; for (const kind of ["source", "author", "tag"]) { const filter = kind === "source" ? DOM.sourceFilter : kind === "author" ? DOM.authorFilter : DOM.tagFilter; if (!filter.querySelector(".sidebar-loading")) continue; if (kind === "source" && !STATE.facets.archives.length && !STATE.facets.types.length) shouldLoadSources = true; else loadFacetPage(kind); } if (shouldLoadSources) loadFacets(); prefetchNextSearchPage(); } function schedulePageRecovery(delay = 0) { clearTimeout(pageRecoveryTimer); pageRecoveryTimer = setTimeout(recoverPageState, delay); } function toggleValue(list, value) { if (!value) return []; return list.includes(value) ? list.filter((item) => item !== value) : [...list, value]; } function filterKeys(kind) { return { include: kind === "source" ? "sources" : kind === "author" ? "authors" : "tags", exclude: kind === "source" ? "excludeSources" : kind === "author" ? "excludeAuthors" : "excludeTags", }; } function cycleFilterValue(kind, value) { if (!value) return; const keys = filterKeys(kind); const included = STATE[keys.include].includes(value); const excluded = STATE[keys.exclude].includes(value); if (included) { STATE[keys.include] = STATE[keys.include].filter((item) => item !== value); STATE[keys.exclude] = [...new Set([...STATE[keys.exclude], value])]; } else if (excluded) { STATE[keys.exclude] = STATE[keys.exclude].filter((item) => item !== value); } else { STATE[keys.exclude] = STATE[keys.exclude].filter((item) => item !== value); STATE[keys.include] = [...new Set([...STATE[keys.include], value])]; } STATE.page = 1; renderFilterOptions({ preserveScroll: true }); doSearch(); } function toggleResultFilter(kind, value) { if (!value) return; const keys = filterKeys(kind); STATE[keys.exclude] = STATE[keys.exclude].filter((item) => item !== value); STATE[keys.include] = toggleValue(STATE[keys.include], value); STATE.page = 1; renderFilterOptions({ preserveScroll: true }); doSearch(); refreshSelectedFacetItem(kind, value); } async function refreshSelectedFacetItem(kind, value) { const dataKey = kind === "source" ? "sources" : kind === "author" ? "authors" : "tags"; const keys = filterKeys(kind); try { const data = await apiWithTimeout(`/api/facet/${kind}?page=1&page_size=500&q=${encodeURIComponent(value)}`); if (!STATE[keys.include].includes(value) && !STATE[keys.exclude].includes(value)) return; const item = (data.items || []).find((candidate) => String(candidate.name) === value); if (!item) return; STATE.facets[dataKey] = sortFacetItems([ ...STATE.facets[dataKey].filter((candidate) => String(candidate.name) !== value), item, ]); renderFacetFilter(kind); } catch (error) { if (error.name !== "AbortError") console.warn("筛选数量加载失败", error); } } function toggleResultDateTerm(value) { const payload = resultDateTermPayload(value); if (!payload) return; const key = dateTermKey(payload); const activeTerms = uniqDateTerms(STATE.dateTerms); const exists = activeTerms.some((item) => dateTermKey(item) === key); STATE.dateTerms = exists ? activeTerms.filter((item) => dateTermKey(item) !== key) : uniqDateTerms([...activeTerms, payload]); if (!STATE.dateTerms.length) STATE.dateTerms = [{ year: "", month: "", day: "" }]; STATE.page = 1; renderDateControls(); doSearch(); } function cycleFilterPage(kind) { const key = kind === "source" ? "sources" : kind === "author" ? "authors" : "tags"; const keys = filterKeys(kind); const values = (STATE.facets[key] || []).map((item) => String(item.name == null ? "" : item.name)).filter(Boolean); const includedSet = new Set(STATE[keys.include]); const excludedSet = new Set(STATE[keys.exclude]); const allIncluded = Boolean(values.length) && values.every((value) => includedSet.has(value)); const allExcluded = Boolean(values.length) && values.every((value) => excludedSet.has(value)); if (allIncluded) { STATE[keys.include] = STATE[keys.include].filter((value) => !values.includes(value)); } else if (allExcluded) { STATE[keys.exclude] = STATE[keys.exclude].filter((value) => !values.includes(value)); } else { STATE[keys.exclude] = STATE[keys.exclude].filter((value) => !values.includes(value)); STATE[keys.include] = [...new Set([...STATE[keys.include], ...values])]; } STATE.page = 1; renderFilterOptions({ preserveScroll: true }); doSearch(); } function clearFilterKind(kind) { const keys = filterKeys(kind); STATE[keys.include] = []; STATE[keys.exclude] = []; STATE.page = 1; renderFilterOptions({ preserveScroll: true }); doSearch(); } async function doSearch() { if (!validateDateRange()) return; const requestSeq = searchRequestSeq + 1; searchRequestSeq = requestSeq; if (searchAbortController) searchAbortController.abort(); abortSearchPrefetch(); searchAbortController = new AbortController(); applyStateToDom(); syncUrl(); DOM.statusText.textContent = "搜索中..."; const payload = searchPayload(); const cacheKey = stableStringify(payload); try { const cached = getCachedSearch(cacheKey); const data = cached || await apiWithTimeout("/api/search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), signal: searchAbortController.signal, }, 25000); if (requestSeq !== searchRequestSeq) return; if (data.index_status === "failed") { STATE.results = []; STATE.total = 0; renderResults(); DOM.statusText.textContent = "索引失败,请看日志"; return; } if (data.indexing && !(data.results || []).length) { STATE.results = []; STATE.total = 0; renderResults(); DOM.statusText.textContent = "索引构建中..."; updateProgress(); return; } STATE.results = data.results || []; STATE.total = data.total || 0; STATE.pageSize = [10, 20, 50].includes(Number(data.page_size)) ? Number(data.page_size) : STATE.pageSize; if (!cached && data.index_status === "ready" && !data.indexing) setCachedSearch(cacheKey, data); renderResults({ animate: true }); DOM.results.scrollTop = 0; if (STATE.preview) previewResultsScrollTop = 0; DOM.statusText.textContent = ""; if (data.index_status === "ready" && !data.indexing) DOM.indexProgress.textContent = ""; else updateProgress(); scheduleNextSearchPagePrefetch(payload); } catch (error) { if (error.name === "AbortError" || requestSeq !== searchRequestSeq) return; DOM.statusText.textContent = "搜索失败"; showToast("搜索失败"); } finally { if (requestSeq === searchRequestSeq) searchAbortController = null; } } async function updateProgress() { try { const data = await healthWithTimeout(); const progress = data.progress; if (data.es === false) { DOM.indexProgress.textContent = "Elasticsearch 不可用,正在重试..."; clearTimeout(progressTimer); progressTimer = setTimeout(updateProgress, 5000); return; } if (!progress || (data.index_status === "ready" && data.ok !== false)) { DOM.indexProgress.textContent = ""; clearTimeout(progressTimer); return; } const indexed = Number(progress.indexed || data.document_count || 0).toLocaleString(); const elapsed = progress.elapsed_seconds ? ` / ${Math.floor(progress.elapsed_seconds / 60)}分${progress.elapsed_seconds % 60}秒` : ""; const syncing = progress.status === "syncing" && progress.archive_total; const detail = syncing ? `同步数据仓库:${progress.archive_current} / ${progress.archive_total}` : `索引${data.index_status === "failed" ? "失败" : "中"}:${indexed} 条`; DOM.indexProgress.textContent = `${detail}${elapsed}`; clearTimeout(progressTimer); if (data.index_status !== "failed") progressTimer = setTimeout(updateProgress, 5000); } catch (error) { DOM.indexProgress.textContent = ""; } } function renderResults({ animate = false } = {}) { const totalPages = Math.max(1, Math.ceil(STATE.total / STATE.pageSize)); DOM.resultCount.textContent = STATE.total ? `共 ${STATE.total.toLocaleString()} 条结果` : ""; DOM.pageInput.value = String(STATE.page); DOM.pageInput.max = String(totalPages); DOM.pageTotal.textContent = String(totalPages); DOM.prevPage.disabled = STATE.page <= 1; DOM.nextPage.disabled = STATE.page >= totalPages; DOM.results.hidden = !STATE.results.length; DOM.emptyState.style.display = STATE.results.length ? "none" : "flex"; DOM.results.innerHTML = STATE.results.map((item) => resultHTML(item)).join(""); if (animate) animateResultRows(Array.from(DOM.results.children).slice(0, 20)); } function animateResultRows(rows) { if (!motionAllowed()) return; rows.forEach((row, index) => { row.style.setProperty("--result-enter-delay", `${Math.round(24 * Math.sqrt(index))}ms`); row.classList.add("result-enter"); row.addEventListener("animationend", () => { row.classList.remove("result-enter"); row.style.removeProperty("--result-enter-delay"); }, { once: true }); }); } function animatePreviewPanel() { if (!motionAllowed()) return; stopPreviewAnimation(); void DOM.previewPanel.offsetWidth; DOM.previewPanel.classList.add("preview-enter"); const finish = (event) => { if (event.target !== DOM.previewPanel) return; DOM.previewPanel.classList.remove("preview-enter"); DOM.previewPanel.removeEventListener("animationend", finish); if (previewAnimationCleanup === finish) previewAnimationCleanup = null; }; previewAnimationCleanup = finish; DOM.previewPanel.addEventListener("animationend", finish); } function stopPreviewAnimation() { DOM.previewPanel.classList.remove("preview-enter"); if (!previewAnimationCleanup) return; DOM.previewPanel.removeEventListener("animationend", previewAnimationCleanup); previewAnimationCleanup = null; } function resultHTML(item) { const type = item.publication_type ? `/${escapeHTML(item.publication_type)}` : ""; const dates = item.date_display && item.date_display.length ? `/${item.date_display.map((date) => `${escapeHTML(date)}`).join(',')}` : ""; const authorLinks = item.authors && item.authors.length ? `/${item.authors.map((author) => `${escapeHTML(author)}`).join(',')}` : ""; const tags = item.tag_names && item.tag_names.length ? `
${item.tag_names.slice(0, 8).map((tag) => `${escapeHTML(tag)}`).join(' / ')}
` : ""; const snippets = (item.snippet || []).map((snippet) => `
${snippet}
`).join(""); const title = item.title_highlight || escapeHTML(item.title); const sourceDownload = item.source_file_count > 1 ? `打包原文件` : item.source_file_count === 1 ? `下载原文件` : ""; return `
${title}
${escapeHTML(item.publication_name)}${authorLinks}${type}${dates}
下载 TXT${sourceDownload}
${snippets}${tags}
`; } function parseQueueHTML(item) { const start = Number(item.page_start) || 1; const end = Number(item.page_end) || start; const dates = (item.date_display || []).join(" / "); return `
${escapeHTML(item.publication_name || item.title)}
Archive ${escapeHTML(item.archive_id)}/${start === end ? `第 ${start} 页` : `第 ${start}-${end} 页`}${dates ? `/${escapeHTML(dates)}` : ""}
`; } function renderParseQueue({ animate = false } = {}) { const pageSize = STATE.parsePageSize; const totalPages = Math.max(1, Math.ceil(STATE.parseTotal / pageSize)); DOM.resultCount.textContent = `待解析 ${STATE.parseTotal.toLocaleString()} 份`; DOM.statusText.textContent = "Archive 25"; DOM.pageInput.value = String(STATE.parsePage); DOM.pageInput.max = String(totalPages); DOM.pageTotal.textContent = String(totalPages); DOM.prevPage.disabled = STATE.parsePage <= 1; DOM.nextPage.disabled = STATE.parsePage >= totalPages; DOM.results.hidden = !STATE.parseItems.length; DOM.emptyState.style.display = STATE.parseItems.length ? "none" : "flex"; DOM.results.innerHTML = STATE.parseItems.map(parseQueueHTML).join(""); if (animate) animateResultRows(Array.from(DOM.results.children).slice(0, 20)); } async function loadParseQueue() { STATE.parseItem = null; STATE.preview = ""; clearPreviewDom(); DOM.statusText.textContent = "正在加载待解析队列..."; try { const data = await apiWithTimeout(`/api/parse/queue?page=${STATE.parsePage}&page_size=${STATE.parsePageSize}`); if (!STATE.parseMode) return; STATE.parseTotal = Number(data.total) || 0; STATE.parseItems = Array.isArray(data.items) ? data.items : []; renderParseQueue({ animate: true }); } catch (error) { DOM.statusText.textContent = "待解析队列加载失败"; showToast(`待解析队列加载失败:${error.message || "未知错误"}`); } } function parseDateParts(article) { const date = Array.isArray(article && article.dates) ? article.dates[0] || {} : {}; return { year: date.year || "", month: date.month || "", day: date.day || "" }; } function parseArticleRowHTML(article, index, maxPage) { const date = parseDateParts(article); const ocr = escapeHTML(JSON.stringify(article.ocr || {})); const exceptions = escapeHTML(JSON.stringify(article.ocr_exceptions || {})); return `
文章 ${index + 1}
`; } function parseEditorHTML(item) { const article = item.article || {}; const maxPage = Number(article.page_end) || (item.source_files || []).length || 1; const initial = { title: "", authors: [], dates: article.dates || [], page_start: 1, page_end: maxPage }; const sourceCount = Number(item.source_file_count) || (item.source_files || []).length; const sourceDownload = sourceCount > 1 ? `打包下载原文件` : sourceCount === 1 ? `下载原文件` : ""; return `
${escapeHTML(item.publication_name || item.title)}
待解析 / ${maxPage} 页
原图与 OCR
1 / ${maxPage}${sourceDownload}
原文件第 1 页
正在加载 OCR...
文章列表
${parseArticleRowHTML(initial, 0, maxPage)}
`; } async function loadParsePage(page) { const item = STATE.parseItem; if (!item) return; const maxPage = Number((item.article || {}).page_end) || (item.source_files || []).length || 1; page = Math.min(maxPage, Math.max(1, Number(page) || 1)); STATE.parseCurrentPage = page; const cropToolbar = DOM.previewPanel.querySelector("[data-parse-crop-toolbar]"); if (cropToolbar && !cropToolbar.hidden) { const selected = parseCropTargetRow(); const coversPage = selected && Number(selected.querySelector("[data-parse-start]").value) <= page && Number(selected.querySelector("[data-parse-end]").value) >= page; if (!coversPage) { if (selected) selected.classList.remove("is-crop-target"); setParseCropTarget(parseCropTargetRow()); } } const image = DOM.previewPanel.querySelector("[data-parse-image]"); const text = DOM.previewPanel.querySelector("[data-parse-ocr]"); const input = DOM.previewPanel.querySelector("[data-parse-page]"); const status = DOM.previewPanel.querySelector("[data-parse-page-status]"); if (input) input.value = String(page); if (status) status.textContent = `${page} / ${maxPage}`; const previous = DOM.previewPanel.querySelector("[data-parse-page-prev]"); const next = DOM.previewPanel.querySelector("[data-parse-page-next]"); if (previous) previous.disabled = page <= 1; if (next) next.disabled = page >= maxPage; if (image) image.src = parseSourceImageURL(item, page); if (text) text.textContent = "正在加载 OCR..."; try { const data = await parseOCRPage(item, page); if (!STATE.parseItem || STATE.parseItem.doc_id !== item.doc_id || STATE.parseCurrentPage !== page) return; if (text) text.textContent = parseOCRText(data, parseOCRThresholds(parseCropTargetRow(), page)); updateParseCropUI(); } catch (error) { if (text) text.textContent = `OCR 加载失败:${error.message || "未知错误"}`; } } function parseOCRThresholds(row, page) { if (!row) return [0, 0, 0, 0]; let ocr = {}; let exceptions = {}; try { ocr = JSON.parse(row.dataset.parseOcr || "{}"); } catch (_error) { /* invalid local state falls back to no crop */ } try { exceptions = JSON.parse(row.dataset.parseOcrExceptions || "{}"); } catch (_error) { /* invalid local state falls back to no crop */ } const options = { ...ocr, ...(exceptions[String(page)] || {}) }; const values = Array.isArray(options.content_thresholds) ? options.content_thresholds : [0, 0, 0, 0]; return values.map((value) => Math.min(0.98, Math.max(0, Number(value) || 0))); } function parseOCRRows(data, thresholds = [0, 0, 0, 0]) { const dimensions = data.dimensions || {}; const width = Number(dimensions.width) || 0; const height = Number(dimensions.height) || 0; const [top, bottom, left, right] = thresholds; return (data.ocr_results || []).filter((row) => { if (!width || !height || !Array.isArray(row.box) || !row.box.length) return true; const xs = row.box.map((point) => Number(point[0]) || 0); const ys = row.box.map((point) => Number(point[1]) || 0); const centerX = (Math.min(...xs) + Math.max(...xs)) / 2; const centerY = (Math.min(...ys) + Math.max(...ys)) / 2; return centerX >= left * width && centerX <= (1 - right) * width && centerY >= top * height && centerY <= (1 - bottom) * height; }).slice().sort((a, b) => ((a.box || [[0, 0]])[0][1] - (b.box || [[0, 0]])[0][1]) || ((a.box || [[0, 0]])[0][0] - (b.box || [[0, 0]])[0][0])); } function parseOCRText(data, thresholds = [0, 0, 0, 0]) { return parseOCRRows(data, thresholds) .map((row) => row.text || "").filter(Boolean).join("\n"); } function parseCropTargetRow() { const rows = Array.from(DOM.previewPanel.querySelectorAll("[data-parse-article]")); const selected = rows.find((row) => row.classList.contains("is-crop-target")); if (selected) return selected; return rows.find((row) => { const start = Number(row.querySelector("[data-parse-start]").value); const end = Number(row.querySelector("[data-parse-end]").value); return start <= STATE.parseCurrentPage && end >= STATE.parseCurrentPage; }) || rows[0] || null; } function setParseCropTarget(row) { DOM.previewPanel.querySelectorAll("[data-parse-article]").forEach((candidate) => candidate.classList.toggle("is-crop-target", candidate === row)); updateParseCropUI(); } function parseCropScope() { const toolbar = DOM.previewPanel.querySelector("[data-parse-crop-toolbar]"); return toolbar && toolbar.dataset.scope === "page" ? "page" : "article"; } function setParseCropScope(scope) { const toolbar = DOM.previewPanel.querySelector("[data-parse-crop-toolbar]"); if (!toolbar) return; toolbar.dataset.scope = scope === "page" ? "page" : "article"; toolbar.querySelectorAll("[data-parse-crop-scope]").forEach((button) => button.classList.toggle("primary", button.dataset.parseCropScope === toolbar.dataset.scope)); updateParseCropUI(); } function cropOptions(row, scope, page) { let options = {}; try { if (scope === "page") { const exceptions = JSON.parse(row.dataset.parseOcrExceptions || "{}"); options = exceptions[String(page)] || {}; } else { options = JSON.parse(row.dataset.parseOcr || "{}"); } } catch (_error) { /* invalid local state falls back to defaults */ } return options; } function setParseCropThresholds(row, scope, page, thresholds, reset = false) { if (!row) return; const key = scope === "page" ? "parseOcrExceptions" : "parseOcr"; let value = {}; try { value = JSON.parse(row.dataset[key] || "{}"); } catch (_error) { value = {}; } if (scope === "page") { if (reset) delete value[String(page)]; else value[String(page)] = { ...(value[String(page)] || {}), content_thresholds: thresholds }; } else if (reset) { delete value.content_thresholds; } else { value.content_thresholds = thresholds; } row.dataset[key] = JSON.stringify(value); } function updateParseCropUI() { const toolbar = DOM.previewPanel.querySelector("[data-parse-crop-toolbar]"); const box = DOM.previewPanel.querySelector("[data-parse-crop-box]"); if (!toolbar || !box || toolbar.hidden) return; const row = parseCropTargetRow(); if (!row) return; const rows = Array.from(DOM.previewPanel.querySelectorAll("[data-parse-article]")); const label = toolbar.querySelector("[data-parse-crop-target]"); const scope = parseCropScope(); const page = STATE.parseCurrentPage; const options = cropOptions(row, scope, page); const thresholds = Array.isArray(options.content_thresholds) ? options.content_thresholds.map((value) => Number(value) || 0) : parseOCRThresholds(row, page); if (label) label.textContent = `文章 ${rows.indexOf(row) + 1}${scope === "page" ? ` / 第 ${page} 页` : ""}`; box.style.top = `${thresholds[0] * 100}%`; box.style.bottom = `${thresholds[1] * 100}%`; box.style.left = `${thresholds[2] * 100}%`; box.style.right = `${thresholds[3] * 100}%`; if (!box.querySelector("[data-parse-crop-handle]")) { box.insertAdjacentHTML("beforeend", ''); } box.classList.toggle("has-selection", thresholds.some((value) => value > 0)); const values = toolbar.querySelector("[data-parse-crop-values]"); if (values) values.textContent = `上 ${Math.round(thresholds[0] * 100)}% 下 ${Math.round(thresholds[1] * 100)}% 左 ${Math.round(thresholds[2] * 100)}% 右 ${Math.round(thresholds[3] * 100)}%`; } function openParseCrop(row = null) { const toolbar = DOM.previewPanel.querySelector("[data-parse-crop-toolbar]"); const overlay = DOM.previewPanel.querySelector("[data-parse-crop-overlay]"); if (!toolbar || !overlay) return; row = row || parseCropTargetRow(); if (!row) return; const start = Number(row.querySelector("[data-parse-start]").value) || 1; const end = Number(row.querySelector("[data-parse-end]").value) || start; if (STATE.parseCurrentPage < start || STATE.parseCurrentPage > end) loadParsePage(start); toolbar.hidden = false; overlay.hidden = false; setParseCropTarget(row); setParseCropScope("article"); } function closeParseCrop() { const toolbar = DOM.previewPanel.querySelector("[data-parse-crop-toolbar]"); const overlay = DOM.previewPanel.querySelector("[data-parse-crop-overlay]"); if (toolbar) toolbar.hidden = true; if (overlay) overlay.hidden = true; parseCropDrag = null; } async function updateParsePageOCR() { const item = STATE.parseItem; const text = DOM.previewPanel.querySelector("[data-parse-ocr]"); if (!item || !text) return; try { const page = STATE.parseCurrentPage; const data = await parseOCRPage(item, page); if (!STATE.parseItem || STATE.parseItem.doc_id !== item.doc_id || STATE.parseCurrentPage !== page) return; text.textContent = parseOCRText(data, parseOCRThresholds(parseCropTargetRow(), page)); } catch (error) { text.textContent = `OCR 加载失败:${error.message || "未知错误"}`; } } function finishParseCropChange(row) { if (!row) return; const textarea = row.querySelector("[data-parse-content]"); const status = row.querySelector("[data-parse-body-status]"); if (textarea && textarea.dataset.edited === "1") { if (status) status.textContent = "裁剪已更改,需刷新正文"; } else { refreshParseArticleBody(row, true, true); } saveParseDraft(); } function beginParseCropDrag(event) { const handle = event.target.closest("[data-parse-crop-handle]"); const box = event.target.closest("[data-parse-crop-box]"); const overlay = event.target.closest("[data-parse-crop-overlay]"); const row = parseCropTargetRow(); if (!overlay || !row || event.button !== 0) return; event.preventDefault(); const page = STATE.parseCurrentPage; const scope = parseCropScope(); const options = cropOptions(row, scope, page); const thresholds = Array.isArray(options.content_thresholds) ? options.content_thresholds.map((value) => Number(value) || 0) : parseOCRThresholds(row, page); parseCropDrag = { row, scope, page, thresholds, mode: handle ? "resize" : box && box.classList.contains("has-selection") ? "move" : "select", corner: handle ? handle.dataset.parseCropHandle : "", startX: event.clientX, startY: event.clientY, rect: overlay.getBoundingClientRect(), changed: false, }; if (box) box.classList.add("is-adjusting"); if (overlay.setPointerCapture) overlay.setPointerCapture(event.pointerId); } function moveParseCrop(event) { const drag = parseCropDrag; if (!drag || !drag.rect.width || !drag.rect.height) return; event.preventDefault(); const minimum = 0.02; const startX = Math.min(1, Math.max(0, (drag.startX - drag.rect.left) / drag.rect.width)); const startY = Math.min(1, Math.max(0, (drag.startY - drag.rect.top) / drag.rect.height)); const currentX = Math.min(1, Math.max(0, (event.clientX - drag.rect.left) / drag.rect.width)); const currentY = Math.min(1, Math.max(0, (event.clientY - drag.rect.top) / drag.rect.height)); let top = drag.thresholds[0]; let bottom = 1 - drag.thresholds[1]; let left = drag.thresholds[2]; let right = 1 - drag.thresholds[3]; if (drag.mode === "select") { top = Math.min(startY, currentY); bottom = Math.max(startY, currentY); left = Math.min(startX, currentX); right = Math.max(startX, currentX); } else if (drag.mode === "move") { const width = right - left; const height = bottom - top; left = Math.min(1 - width, Math.max(0, left + currentX - startX)); top = Math.min(1 - height, Math.max(0, top + currentY - startY)); right = left + width; bottom = top + height; } else { if (drag.corner.includes("n")) top = Math.min(bottom - minimum, currentY); if (drag.corner.includes("s")) bottom = Math.max(top + minimum, currentY); if (drag.corner.includes("w")) left = Math.min(right - minimum, currentX); if (drag.corner.includes("e")) right = Math.max(left + minimum, currentX); } if (bottom - top < minimum || right - left < minimum) return; const values = [top, 1 - bottom, left, 1 - right]; const rounded = values.map((value) => Math.round(value * 10000) / 10000); setParseCropThresholds(drag.row, drag.scope, drag.page, rounded); drag.changed = true; updateParseCropUI(); } function endParseCrop() { if (!parseCropDrag) return; const row = parseCropDrag.row; const changed = parseCropDrag.changed; parseCropDrag = null; const box = DOM.previewPanel.querySelector("[data-parse-crop-box]"); if (box) box.classList.remove("is-adjusting"); if (!changed) return; updateParsePageOCR(); finishParseCropChange(row); } async function parseOCRPage(item, page) { const key = `${item.doc_id}:${page}`; if (parseOCRCache.has(key)) { const cached = parseOCRCache.get(key); parseOCRCache.delete(key); parseOCRCache.set(key, cached); return cached; } const data = await apiWithTimeout(`/api/parse/${encodeURIComponent(item.doc_id)}/ocr?page=${page}`, {}, 30000); parseOCRCache.set(key, data); while (parseOCRCache.size > PARSE_OCR_CACHE_LIMIT) parseOCRCache.delete(parseOCRCache.keys().next().value); return data; } async function refreshParseArticleBody(row, force = false, persist = false, preserveEdited = false) { if (!row || !STATE.parseItem) return; const textarea = row.querySelector("[data-parse-content]"); const status = row.querySelector("[data-parse-body-status]"); if (!force && textarea.dataset.edited === "1") return; const start = Number(row.querySelector("[data-parse-start]").value); const end = Number(row.querySelector("[data-parse-end]").value); const maxPage = Number(row.closest("[data-parse-form]").dataset.maxPage) || 1; if (!start || !end || start < 1 || end > maxPage || start > end) { row.dataset.basePartCount = ""; if (status) status.textContent = "页码无效"; return; } const sourceValue = textarea.value; const preserveCurrent = preserveEdited && textarea.dataset.edited === "1"; row.dataset.basePartCount = ""; if (status) status.textContent = "正在读取..."; try { const pages = await Promise.all(Array.from({ length: end - start + 1 }, (_value, index) => parseOCRPage(STATE.parseItem, start + index))); const pageTexts = pages.map((data, index) => parseOCRText(data, parseOCRThresholds(row, start + index))); const body = pageTexts.filter(Boolean).join("\n"); const boxCount = pages.reduce((total, data, index) => total + parseOCRRows(data, parseOCRThresholds(row, start + index)).length, 0); if (preserveCurrent || (textarea.dataset.edited === "1" && textarea.value !== sourceValue)) { row.dataset.basePartCount = String(boxCount); if (status) status.textContent = preserveCurrent ? "已恢复草稿" : "已编辑"; if (persist) saveParseDraft(); return; } textarea.value = body; textarea.dataset.edited = "0"; row.dataset.basePartCount = String(boxCount); if (status) status.textContent = `${body.length.toLocaleString()} 字`; if (persist) saveParseDraft(); } catch (error) { row.dataset.basePartCount = ""; if (status) status.textContent = "OCR 读取失败"; } } async function openParseEditor(docId) { const queueItem = STATE.parseItems.find((item) => item.doc_id === docId); if (!queueItem) return; try { const item = await apiWithTimeout(`/api/preview/${encodeURIComponent(docId)}`, {}, 30000); STATE.parseItem = item; STATE.preview = docId; applyStateToDom(); DOM.previewPanel.hidden = false; DOM.previewPanel.style.display = ""; DOM.previewPanel.className = "preview-panel parse-panel"; DOM.previewPanel.innerHTML = parseEditorHTML(item); DOM.results.hidden = true; DOM.emptyState.style.display = "none"; animatePreviewPanel(); const restoredPage = restoreParseDraft(item, DOM.previewPanel.querySelector("[data-parse-form]")); loadParsePage(restoredPage || 1); DOM.previewPanel.querySelectorAll("[data-parse-article]").forEach((row) => { if (!Number(row.dataset.basePartCount)) refreshParseArticleBody(row, true, false, true); }); } catch (error) { showToast(`解析资料加载失败:${error.message || "未知错误"}`); } } function collectParseArticles(form) { return Array.from(form.querySelectorAll("[data-parse-article]")).map((row) => { const year = Number(row.querySelector("[data-parse-year]").value) || null; const month = Number(row.querySelector("[data-parse-month]").value) || null; const day = Number(row.querySelector("[data-parse-day]").value) || null; const date = {}; if (year) date.year = year; if (month) date.month = month; if (day) date.day = day; let ocr = {}; let ocrExceptions = {}; try { ocr = JSON.parse(row.dataset.parseOcr || "{}"); } catch (_error) { /* validation reports malformed state */ } try { ocrExceptions = JSON.parse(row.dataset.parseOcrExceptions || "{}"); } catch (_error) { /* validation reports malformed state */ } const article = { title: row.querySelector("[data-parse-title]").value.trim(), authors: row.querySelector("[data-parse-authors]").value.split(/[、,,/]/).map((value) => value.trim()).filter(Boolean), dates: Object.keys(date).length ? [date] : [], is_range_date: false, page_start: Number(row.querySelector("[data-parse-start]").value), page_end: Number(row.querySelector("[data-parse-end]").value), content: row.querySelector("[data-parse-content]").value, base_part_count: Number(row.dataset.basePartCount) || 0, }; if (Object.keys(ocr).length) article.ocr = ocr; if (Object.keys(ocrExceptions).length) article.ocr_exceptions = ocrExceptions; return article; }); } function parseDraftKey(docId) { return `bha-parse-draft:${docId}`; } function collectParseDraft(form) { if (!form || !STATE.parseItem || form.dataset.parseSubmitted === "1") return null; const rows = Array.from(form.querySelectorAll("[data-parse-article]")); const articles = collectParseArticles(form).map((article, index) => ({ ...article, content_edited: rows[index].querySelector("[data-parse-content]").dataset.edited === "1", })); return { content_hash: STATE.parseItem.content_hash, articles, description: (form.querySelector("[data-parse-description]") || {}).value || "", page: STATE.parseCurrentPage, }; } function flushParseDraft() { clearTimeout(parseDraftTimer); parseDraftTimer = 0; if (!STATE.parseItem) return; const draft = collectParseDraft(DOM.previewPanel.querySelector("[data-parse-form]")); if (draft) safeSessionSet(parseDraftKey(STATE.parseItem.doc_id), JSON.stringify(draft)); } function saveParseDraft() { clearTimeout(parseDraftTimer); parseDraftTimer = setTimeout(flushParseDraft, 400); } function clearParseDraft(docId) { clearTimeout(parseDraftTimer); parseDraftTimer = 0; try { sessionStorage.removeItem(parseDraftKey(docId)); } catch (_error) { /* storage is optional */ } } function restoreParseDraft(item, form) { if (!form) return 0; let draft; try { draft = JSON.parse(safeSessionGet(parseDraftKey(item.doc_id)) || "null"); } catch (_error) { draft = null; } if (!draft) return 0; if (draft.content_hash !== item.content_hash || !Array.isArray(draft.articles) || !draft.articles.length) { clearParseDraft(item.doc_id); return 0; } const maxPage = Number(form.dataset.maxPage) || 1; try { const holder = form.querySelector("[data-parse-articles]"); holder.innerHTML = draft.articles.map((article, index) => parseArticleRowHTML(article, index, maxPage)).join(""); holder.querySelectorAll("[data-parse-article]").forEach((row, index) => { const article = draft.articles[index]; row.dataset.basePartCount = String(Number(article.base_part_count) || ""); const textarea = row.querySelector("[data-parse-content]"); textarea.dataset.edited = article.content_edited ? "1" : "0"; const status = row.querySelector("[data-parse-body-status]"); if (status) status.textContent = article.content_edited ? "已恢复草稿" : `${textarea.value.length.toLocaleString()} 字`; }); const description = form.querySelector("[data-parse-description]"); if (description) description.value = typeof draft.description === "string" ? draft.description : ""; return Math.min(maxPage, Math.max(1, Number(draft.page) || 1)); } catch (_error) { clearParseDraft(item.doc_id); return 0; } } function parseCoverageError(articles, maxPage) { if (articles.some((article) => !article.title)) return "每篇文章都需要标题"; if (articles.some((article) => !article.content.trim())) return "每篇文章都需要正文"; if (articles.some((article) => !article.base_part_count)) return "正文 OCR 基线加载失败,请检查网络后重试"; if (articles.some((article) => article.page_start < 1 || article.page_end > maxPage || article.page_start > article.page_end)) return "文章页码范围无效"; const covered = new Set(); articles.forEach((article) => { for (let page = article.page_start; page <= article.page_end; page += 1) covered.add(page); }); const missing = Array.from({ length: maxPage }, (_value, index) => index + 1).filter((page) => !covered.has(page)); return missing.length ? `以下页面尚未被文章覆盖:${missing.join("、")}` : ""; } function parseArticleThresholds(article, page) { const options = { ...(article.ocr || {}), ...((article.ocr_exceptions || {})[String(page)] || {}) }; const thresholds = Array.isArray(options.content_thresholds) ? options.content_thresholds.map((value) => Number(value) || 0) : [0, 0, 0, 0]; return thresholds.length === 4 ? thresholds : [0, 0, 0, 0]; } function parseSourceImageURL(item, page) { const files = Array.isArray(item.source_files) ? item.source_files : []; const fileIndex = files.length === 1 ? 0 : page - 1; const file = files[fileIndex] || {}; const pageCount = Number(file.page_count) || (Array.isArray(file.pages) ? file.pages.length : 0); if (pageCount) return pageImageURL(item.doc_id, fileIndex, files.length === 1 ? page : 1); return sourceDownloadURL(item, fileIndex, true); } function parseCropLabel(thresholds) { return `上 ${Math.round(thresholds[0] * 100)}% / 下 ${Math.round(thresholds[1] * 100)}% / 左 ${Math.round(thresholds[2] * 100)}% / 右 ${Math.round(thresholds[3] * 100)}%`; } function parseReviewHTML(item, payload) { const rows = payload.articles.map((article, index) => { const date = (article.dates || []).map((value) => metadataItemText("dates", value)).filter(Boolean).join("、") || "(空)"; const authors = (article.authors || []).join("、") || "(空)"; const crops = []; for (let page = article.page_start; page <= article.page_end; page += 1) { const thresholds = parseArticleThresholds(article, page); if (!thresholds.some((value) => value > 0)) continue; crops.push(`
第 ${page} 页 · ${escapeHTML(parseCropLabel(thresholds))}
`); } return `
文章 ${index + 1} · ${escapeHTML(article.title)}
作者:${escapeHTML(authors)}日期:${escapeHTML(date)}页码:${article.page_start === article.page_end ? article.page_start : `${article.page_start}–${article.page_end}`}OCR 基线:${article.base_part_count} 段
${escapeHTML(article.content)}
${crops.length ? `
${crops.join("")}
` : ""}
`; }); if (payload.description) rows.push(`
提交说明
${escapeHTML(payload.description)}
`); return rows.join(""); } function hydrateParseReviewCrops(holder, item) { holder.querySelectorAll("[data-parse-review-crop]").forEach((canvas) => { const page = Number(canvas.dataset.page); let thresholds; try { thresholds = JSON.parse(canvas.dataset.thresholds); } catch (_error) { thresholds = null; } const error = canvas.parentElement.querySelector("[data-parse-review-crop-error]"); if (!Array.isArray(thresholds) || thresholds.length !== 4) { canvas.hidden = true; if (error) error.hidden = false; return; } const image = new Image(); image.onload = () => { const [top, bottom, left, right] = thresholds; const sourceX = Math.floor(left * image.naturalWidth); const sourceY = Math.floor(top * image.naturalHeight); const sourceWidth = Math.max(1, Math.ceil((1 - right) * image.naturalWidth) - sourceX); const sourceHeight = Math.max(1, Math.ceil((1 - bottom) * image.naturalHeight) - sourceY); const scale = Math.min(1, 1000 / sourceWidth, 700 / sourceHeight); canvas.width = Math.max(1, Math.round(sourceWidth * scale)); canvas.height = Math.max(1, Math.round(sourceHeight * scale)); canvas.getContext("2d").drawImage(image, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, canvas.width, canvas.height); }; image.onerror = () => { canvas.hidden = true; if (error) error.hidden = false; }; image.src = parseSourceImageURL(item, page); }); } function removeParseConfirm() { const panel = document.querySelector("[data-parse-confirm]"); if (panel) panel.remove(); } function showParseConfirm(form, item, payload) { removeParseConfirm(); const holder = document.createElement("div"); holder.className = "proofread-confirm"; holder.dataset.parseConfirm = "true"; holder.innerHTML = ``; document.body.append(holder); hydrateParseReviewCrops(holder, item); holder.querySelectorAll("[data-cancel-parse]").forEach((button) => button.addEventListener("click", removeParseConfirm)); holder.querySelector("[data-confirm-parse]").addEventListener("click", async () => { const button = holder.querySelector("[data-confirm-parse]"); button.disabled = true; button.textContent = "提交中..."; const submitted = await postParse(form, item, payload); if (submitted) removeParseConfirm(); else { button.disabled = false; button.textContent = "确认提交"; } }); } async function postParse(form, item, payload) { try { await apiWithTimeout("/api/parse", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ doc_id: item.doc_id, base_content_hash: item.content_hash, ...payload }) }, 30000); form.dataset.parseSubmitted = "1"; clearParseDraft(item.doc_id); showToast("解析已提交,等待人工审核"); form.querySelector('button[type="submit"]').disabled = true; return true; } catch (submitError) { showToast(`解析提交失败:${submitError.message || "未知错误"}`); return false; } } async function submitParseForm(form) { const item = STATE.parseItem; if (!item) return; let articles = collectParseArticles(form); const maxPage = Number(form.dataset.maxPage) || 1; const errorNode = form.querySelector("[data-parse-error]"); if (articles.some((article) => !article.base_part_count)) { const button = form.querySelector('button[type="submit"]'); const label = button ? button.textContent : ""; if (button) { button.disabled = true; button.textContent = "正在读取 OCR 基线..."; } errorNode.hidden = true; const rows = Array.from(form.querySelectorAll("[data-parse-article]")); await Promise.all(rows.filter((row) => !Number(row.dataset.basePartCount)).map((row) => ( refreshParseArticleBody(row, true, false, true) ))); if (button) { button.disabled = false; button.textContent = label; } articles = collectParseArticles(form); } const error = parseCoverageError(articles, maxPage); if (error) { errorNode.textContent = error; errorNode.hidden = false; return; } errorNode.hidden = true; flushParseDraft(); showParseConfirm(form, item, { articles, description: form.querySelector("[data-parse-description]").value.trim() }); } function scheduleSearch() { if (searchComposing) return; clearTimeout(searchTimer); searchTimer = setTimeout(() => { STATE.parseMode = false; STATE.q = DOM.searchInput.value.trim(); STATE.page = 1; doSearch(); }, 120); } async function restorePreview(scroll = true) { if (!STATE.preview) { cancelPreviewRequests(); clearPreviewDom(); if (!searchAbortController && !bootstrapPending) renderResults(); return; } const docId = STATE.preview; const requestSeq = previewRequestSeq + 1; previewRequestSeq = requestSeq; if (previewAbortController) { previewAbortController.abort(); previewAbortController = null; } clearActiveMediaViewer(); try { let item = getCachedPreview(docId); if (!item) { previewAbortController = new AbortController(); const controller = previewAbortController; item = await apiWithTimeout(`/api/preview/${encodeURIComponent(docId)}`, { signal: controller.signal }); setCachedPreview(docId, item); if (previewAbortController === controller) previewAbortController = null; } if (requestSeq !== previewRequestSeq || STATE.preview !== docId) return; clearActiveMediaViewer(); DOM.previewPanel.classList.remove("proofread-mode"); DOM.previewPanel.innerHTML = previewHTML(item); prefetchPreviewFirstPage(item); DOM.previewPanel.hidden = false; DOM.previewPanel.style.display = "flex"; document.body.classList.add("preview-open"); DOM.emptyState.style.display = "none"; if (animateNextPreview) animatePreviewPanel(); animateNextPreview = false; if (STATE.proofread) { openProofread(item, null, true); } else if (STATE.compare) { comparePreview(STATE.compare, true); } const previewBody = DOM.previewPanel.querySelector(".preview-body"); if (scroll && previewBody) previewBody.scrollTo({ top: 0, behavior: "smooth" }); } catch (error) { if (requestSeq !== previewRequestSeq || STATE.preview !== docId) return; animateNextPreview = false; if (error.name === "AbortError") return; showToast("预览失败"); closePreview(true); } } function openPreview(docId, scroll = true) { if (!docId) return; flushProofreadDraft(); const replacingPreview = history.state && history.state.previewEntry === true; if (!replacingPreview) previewResultsScrollTop = DOM.results.scrollTop; STATE.preview = docId; STATE.compare = ""; STATE.proofread = false; STATE.proofreadFile = ""; STATE.proofreadPage = ""; animateNextPreview = true; applyStateToDom(); syncUrl(replacingPreview); restorePreview(scroll); } function previewHTML(item, compareItem = null) { const themeAction = ``; const commands = `${previewDownloadHTML(item)}${compareSelectHTML(item)}`; const actions = `
${commands}
${themeAction}
`; if (compareItem) { const diff = diffHTML(item.content || "", compareItem.content || ""); return `
${previewTitleHTML(item)}
正在对比:${escapeHTML(item.publication_name)} / ${escapeHTML(compareItem.publication_name)}
${actions}
${previewMetaHTML(item)}
${diff.left}
${previewMetaHTML(compareItem)}
${diff.right}
`; } return `
${previewTitleHTML(item)}${previewMetaHTML(item)}
${actions}
${highlightPreviewText(item.content || "")}
`; } function previewDownloadHTML(item) { if (item.source_file_count > 1) { return `打包原文件`; } if (item.source_file_count === 1) { return `下载原文件`; } return ""; } function sourceDownloadURL(item, fileIndex = 0, inline = false) { const route = inline ? "/api/preview/source/" : "/api/download/source/"; return `${route}${encodeURIComponent(item.doc_id)}?file=${encodeURIComponent(fileIndex)}${inline ? "&inline=true" : ""}`; } function pageImageURL(docId, fileIndex, page) { return `/api/preview/source-page/${encodeURIComponent(docId)}?file=${encodeURIComponent(fileIndex)}&page=${encodeURIComponent(page)}`; } function pageImageViewerHTML(file, page, sourceDownload) { const pages = Array.isArray(file && file.pages) ? file.pages : []; const pageNumbers = Array.isArray(file && file.page_numbers) ? file.page_numbers : pages.map((_url, index) => index + 1); const pageCount = Math.max(pages.length, Number(file && file.page_count) || 0); const pageIndex = Math.min(pageCount - 1, Math.max(0, page - 1)); const fileIndex = Number(file && file.index) || 0; const initialPage = Number(file && file.initial_page_number) || 0; const initialURL = file && file.initial_page || ""; const proxyPages = pages.map((url, index) => /^https?:/i.test(url) ? pageImageURL(STATE.preview, fileIndex, pageNumbers[index] || index + 1) : url); const proxyInitial = /^https?:/i.test(initialURL) ? pageImageURL(STATE.preview, fileIndex, initialPage) : initialURL; const initialSource = proxyInitial || proxyPages[0] || ""; return `
下载原文件
原文件第 ${pageIndex + 1} 页
`; } async function setPageImage(viewer, page) { let pages; try { pages = JSON.parse(viewer.dataset.pageImages || "[]"); } catch { pages = []; } let pageNumbers; try { pageNumbers = JSON.parse(viewer.dataset.pageNumbers || "[]"); } catch { pageNumbers = []; } const pageCount = Number(viewer.dataset.pageCount) || pages.length; if (!pageCount) return; const index = Math.min(pageCount - 1, Math.max(0, page - 1)); const image = viewer.querySelector("[data-proofread-source-image]"); const input = viewer.querySelector("[data-page-image-input]"); if (!image) return; if (cancelPageImagePrefetch) { cancelPageImagePrefetch(); cancelPageImagePrefetch = null; } if (pageImagePrefetchController) { pageImagePrefetchController.abort(); pageImagePrefetchController = null; } if (viewer.pageImageAbortController) viewer.pageImageAbortController.abort(); const pageImageAbortController = new AbortController(); viewer.pageImageAbortController = pageImageAbortController; const requestId = String(Number(viewer.dataset.pageRequest || 0) + 1); viewer.dataset.pageRequest = requestId; const knownIndex = pageNumbers.indexOf(index + 1); let url = knownIndex >= 0 ? pages[knownIndex] || "" : ""; if (!url && index === Number(viewer.dataset.pageInitialIndex)) url = viewer.dataset.pageInitialUrl || ""; if (!url) url = pageImageURL(viewer.dataset.pageDocId, viewer.dataset.pageFileIndex, index + 1); if (pageImageAbortController.signal.aborted || viewer.dataset.pageRequest !== requestId || !viewer.isConnected) return; if (viewer.pageImageAbortController === pageImageAbortController) viewer.pageImageAbortController = null; viewer.dataset.pageIndex = String(index); image.dataset.imageScale = "1"; delete image.dataset.imageBaseWidth; delete image.dataset.imageLoadedSrc; image.style.width = ""; image.style.height = "auto"; image.src = url; image.alt = `原文件第 ${index + 1} 页`; if (input) input.value = String(index + 1); const previous = viewer.querySelector("[data-page-image-prev]"); const next = viewer.querySelector("[data-page-image-next]"); if (previous) previous.disabled = index === 0; if (next) next.disabled = index === pageCount - 1; STATE.proofreadPage = String(index + 1); syncUrl(true); saveProofreadDraft(); } function prefetchAdjacentPageImage(viewer) { if (!viewer) return; const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection; if (connection && (connection.saveData || /(^|-)2g$/.test(connection.effectiveType || ""))) return; let pages; try { pages = JSON.parse(viewer.dataset.pageImages || "[]"); } catch { pages = []; } let pageNumbers; try { pageNumbers = JSON.parse(viewer.dataset.pageNumbers || "[]"); } catch { pageNumbers = []; } const currentIndex = Number(viewer.dataset.pageIndex) || 0; const pageCount = Number(viewer.dataset.pageCount) || pages.length; const adjacent = [currentIndex - 1, currentIndex + 1].filter((index) => index >= 0 && index < pageCount); if (!adjacent.length) return; const requests = adjacent.map((index) => { const knownIndex = pageNumbers.indexOf(index + 1); const url = knownIndex >= 0 ? pages[knownIndex] || "" : pageImageURL(viewer.dataset.pageDocId, viewer.dataset.pageFileIndex, index + 1); const cacheKey = url || `${viewer.dataset.pageDocId}:${viewer.dataset.pageFileIndex}:${index + 1}`; if (prefetchedPageImages.has(cacheKey)) return null; prefetchedPageImages.add(cacheKey); while (prefetchedPageImages.size > 32) prefetchedPageImages.delete(prefetchedPageImages.values().next().value); return { index, url }; }).filter(Boolean); if (!requests.length) return; if (pageImagePrefetchController) pageImagePrefetchController.abort(); const load = () => { cancelPageImagePrefetch = null; const controller = new AbortController(); pageImagePrefetchController = controller; const latestConnection = navigator.connection || navigator.mozConnection || navigator.webkitConnection; if (!viewer.isConnected || latestConnection && (latestConnection.saveData || /(^|-)2g$/.test(latestConnection.effectiveType || ""))) { controller.abort(); return; } const loadImage = (url) => { const image = new Image(); image.decoding = "async"; image.fetchPriority = "low"; image.src = url; if (image.decode) image.decode().catch(() => {}); }; requests.forEach(({ url }) => loadImage(url)); }; if (cancelPageImagePrefetch) cancelPageImagePrefetch(); let idleHandle = null; const debounceHandle = setTimeout(() => { if (window.requestIdleCallback) { idleHandle = window.requestIdleCallback(load, { timeout: 1500 }); } else { load(); } }, 800); cancelPageImagePrefetch = () => { clearTimeout(debounceHandle); if (idleHandle !== null && window.cancelIdleCallback) window.cancelIdleCallback(idleHandle); if (pageImagePrefetchController) pageImagePrefetchController.abort(); }; } function prefetchPreviewFirstPage(item) { const file = (item && item.source_files || []).find((candidate) => candidate.initial_page || Array.isArray(candidate.pages) && candidate.pages[0]); if (!file) return; const initialPage = Number(file && file.initial_page_number) || (Array.isArray(file && file.page_numbers) && file.page_numbers[0]) || 1; const firstURL = (file.initial_page || (file.pages || [])[0] || ""); const image = new Image(); image.decoding = "async"; image.fetchPriority = "high"; image.src = /^https?:/i.test(firstURL) ? pageImageURL(item.doc_id, Number(file.index) || 0, initialPage) : firstURL; } function isImageSource(url) { return /\.(?:bmp|gif|jpe?g|png|tiff?|webp)(?:$|\?)/i.test(String(url || "")); } function isPDFSource(url) { return /\.pdf(?:$|\?)/i.test(String(url || "")); } function loadPDFJS() { if (!pdfjsPromise) { pdfjsPromise = import("https://cdnjs.cloudflare.com/ajax/libs/pdf.js/6.2.108/pdf.min.mjs").then((pdfjs) => { pdfjs.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/6.2.108/pdf.worker.min.mjs"; return pdfjs; }).catch((error) => { pdfjsPromise = null; throw error; }); } return pdfjsPromise; } async function readPDFRange(response, begin, end, expectedLength = null) { if (response.status !== 206) { if (response.body) await response.body.cancel(); throw new Error(`PDF range request failed: ${response.status}`); } const contentRange = response.headers.get("Content-Range") || ""; const match = /^bytes\s+(\d+)-(\d+)\/(\d+)$/.exec(contentRange); const rangeBegin = match ? Number(match[1]) : -1; const rangeEnd = match ? Number(match[2]) : -1; const length = match ? Number(match[3]) : 0; const expectedEnd = Math.min(end, length) - 1; if (!Number.isSafeInteger(length) || length <= 0 || (expectedLength != null && length !== expectedLength) || rangeBegin !== begin || rangeEnd !== expectedEnd) { if (response.body) await response.body.cancel(); throw new Error("PDF range response is invalid"); } const data = new Uint8Array(await response.arrayBuffer()); if (data.length !== rangeEnd - rangeBegin + 1) throw new Error("PDF range response length is invalid"); return { data, length }; } async function fetchInitialPDFRange(url, signal) { const initialResponse = await fetch(url, { headers: { Range: "bytes=0-524287" }, signal }); return readPDFRange(initialResponse, 0, 512 * 1024); } async function createPDFRangeTransport(pdfjs, url, signal, initialPromise = null) { const initial = await (initialPromise || fetchInitialPDFRange(url, signal)); class URLRangeTransport extends pdfjs.PDFDataRangeTransport { constructor() { super(initial.length, initial.data, false); this.controllers = new Set(); this.failurePromise = new Promise((_resolve, reject) => { this.rejectFailure = reject; }); this.failed = false; } requestDataRange(begin, end) { const controller = new AbortController(); this.controllers.add(controller); fetch(url, { headers: { Range: `bytes=${begin}-${end - 1}` }, signal: controller.signal, }).then(async (response) => { const range = await readPDFRange(response, begin, end, initial.length); this.onDataRange(begin, range.data); }).catch((error) => { this.abort(error); }).finally(() => { this.controllers.delete(controller); }); } abort(error = null) { for (const controller of this.controllers) controller.abort(); this.controllers.clear(); if (error && !this.failed) { this.failed = true; this.rejectFailure(error); } } } return new URLRangeTransport(); } function destroyPDFCacheEntry(url, entry) { if (pdfDocumentCache.get(url) === entry) pdfDocumentCache.delete(url); if (entry.destroyed) return; entry.destroyed = true; if (entry.initialController) entry.initialController.abort(); if (entry.range) entry.range.abort(); if (entry.loadingTask) entry.loadingTask.destroy().catch(() => {}); else if (entry.document) entry.document.destroy().catch(() => {}); } function trimPDFDocumentCache() { const limit = STATE.isMobile ? PDF_DOCUMENT_MOBILE_CACHE_LIMIT : PDF_DOCUMENT_CACHE_LIMIT; while (pdfDocumentCache.size > limit) { const candidate = [...pdfDocumentCache.keys()].find((url) => !activePDFViewer || activePDFViewer.url !== url); if (!candidate) break; destroyPDFCacheEntry(candidate, pdfDocumentCache.get(candidate)); } } function cleanupPDFDocumentCache(force = false) { clearTimeout(pdfCacheCleanupTimer); pdfCacheCleanupTimer = 0; const now = Date.now(); for (const [url, entry] of [...pdfDocumentCache]) { const active = activePDFViewer && activePDFViewer.url === url; if (force || (!active && now - entry.lastUsed >= PDF_DOCUMENT_CACHE_IDLE_MS)) destroyPDFCacheEntry(url, entry); } if (!force && pdfDocumentCache.size) pdfCacheCleanupTimer = setTimeout(cleanupPDFDocumentCache, 60 * 1000); } function schedulePDFCacheCleanup() { if (!pdfCacheCleanupTimer) pdfCacheCleanupTimer = setTimeout(cleanupPDFDocumentCache, 60 * 1000); } async function getCachedPDFDocument(url) { const cached = pdfDocumentCache.get(url); if (cached) { pdfDocumentCache.delete(url); cached.lastUsed = Date.now(); pdfDocumentCache.set(url, cached); schedulePDFCacheCleanup(); return cached.promise; } const entry = { promise: null, initialController: new AbortController(), range: null, loadingTask: null, document: null, destroyed: false, lastUsed: Date.now() }; const pdfjsLoad = loadPDFJS(); const initialLoad = fetchInitialPDFRange(url, entry.initialController.signal); entry.promise = Promise.all([pdfjsLoad, initialLoad]).then(async ([pdfjs, initial]) => { const range = await createPDFRangeTransport(pdfjs, url, entry.initialController.signal, Promise.resolve(initial)); entry.range = range; entry.loadingTask = pdfjs.getDocument({ range, disableStream: true, disableAutoFetch: true, disableRange: false, rangeChunkSize: 512 * 1024, }); range.failurePromise.catch(() => { destroyPDFCacheEntry(url, entry); }); try { entry.document = await Promise.race([entry.loadingTask.promise, range.failurePromise]); return entry.document; } catch (error) { destroyPDFCacheEntry(url, entry); throw error; } }).catch((error) => { if (pdfDocumentCache.get(url) === entry) pdfDocumentCache.delete(url); throw error; }); pdfDocumentCache.set(url, entry); trimPDFDocumentCache(); schedulePDFCacheCleanup(); return entry.promise; } function clearActiveMediaViewer() { if (cancelPageImagePrefetch) cancelPageImagePrefetch(); cancelPageImagePrefetch = null; if (pageImagePrefetchController) pageImagePrefetchController.abort(); pageImagePrefetchController = null; DOM.previewPanel.querySelectorAll("[data-page-image-viewer]").forEach((viewer) => { if (viewer.pageImageAbortController) { viewer.pageImageAbortController.abort(); viewer.pageImageAbortController = null; } }); if (activeImageResizeObserver) activeImageResizeObserver.disconnect(); cancelAnimationFrame(activeImageFitFrame); activeImageFitFrame = 0; activeImageResizeObserver = null; if (!activePDFViewer) return; activePDFViewer.generation += 1; if (activePDFViewer.renderTask) activePDFViewer.renderTask.cancel(); if (activePDFViewer.resizeObserver) activePDFViewer.resizeObserver.disconnect(); cancelAnimationFrame(activePDFViewer.resizeFrame || 0); const entry = pdfDocumentCache.get(activePDFViewer.url); if (entry && !entry.document) destroyPDFCacheEntry(activePDFViewer.url, entry); activePDFViewer = null; } async function renderPDFViewer(viewer) { const url = viewer.dataset.pdfUrl; const download = viewer.dataset.pdfDownload; const canvas = viewer.querySelector("[data-pdf-canvas]"); const stage = viewer.querySelector(".proofread-pdf-stage"); const status = viewer.querySelector("[data-pdf-status]"); const pageInput = viewer.querySelector("[data-pdf-page]"); if (!url || !canvas) return; if (activePDFViewer) clearActiveMediaViewer(); const lifecycle = { viewer, url, generation: 0, renderTask: null, resizeObserver: null, resizeFrame: 0 }; activePDFViewer = lifecycle; let pdf; let pageNumber = Math.max(1, Number(viewer.dataset.pdfPage) || 1); let zoomOffset = 0; const setStatus = (text) => { if (status) status.textContent = text; }; const showError = (error) => { if (activePDFViewer !== lifecycle || error && error.name === "RenderingCancelledException") return; if (stage) { stage.classList.remove("is-loading"); stage.classList.add("is-error"); } setStatus("PDF 无法在线预览"); if (!viewer.querySelector("[data-pdf-download-link], [data-pdf-fallback]")) { const fallback = document.createElement("a"); fallback.className = "text-btn-sm"; fallback.dataset.pdfFallback = "true"; fallback.href = download || url; fallback.textContent = "下载原文件"; viewer.append(fallback); } }; const renderPage = async () => { const generation = lifecycle.generation + 1; lifecycle.generation = generation; if (lifecycle.renderTask) { lifecycle.renderTask.cancel(); try { await lifecycle.renderTask.promise; } catch (_error) { /* a newer render superseded it */ } lifecycle.renderTask = null; } pageNumber = Math.min(pdf.numPages, Math.max(1, pageNumber)); const page = await pdf.getPage(pageNumber); if (activePDFViewer !== lifecycle || generation !== lifecycle.generation || !viewer.isConnected) return; const unitViewport = page.getViewport({ scale: 1 }); const availableWidth = Math.max(1, (stage ? stage.clientWidth : unitViewport.width) - 32); const fitScale = Math.min(1.5, Math.max(0.5, availableWidth / unitViewport.width)); const scale = Math.min(3, Math.max(0.5, fitScale + zoomOffset)); const viewport = page.getViewport({ scale }); const context = canvas.getContext("2d"); canvas.width = Math.ceil(viewport.width); canvas.height = Math.ceil(viewport.height); canvas.style.width = `${Math.ceil(viewport.width)}px`; canvas.style.height = `${Math.ceil(viewport.height)}px`; lifecycle.renderTask = page.render({ canvasContext: context, viewport }); try { await lifecycle.renderTask.promise; } catch (error) { if (error && error.name === "RenderingCancelledException") return false; throw error; } finally { if (generation === lifecycle.generation) lifecycle.renderTask = null; } if (activePDFViewer !== lifecycle || generation !== lifecycle.generation || !viewer.isConnected) return false; if (pageInput) { pageInput.value = String(pageNumber); pageInput.max = String(pdf.numPages); } viewer.dataset.pdfPage = String(pageNumber); STATE.proofreadPage = String(pageNumber); syncUrl(true); setStatus(`${pageNumber} / ${pdf.numPages}`); return true; }; const requestRender = () => { renderPage().catch(showError); }; try { setStatus("正在加载 PDF..."); pdf = await getCachedPDFDocument(url); if (activePDFViewer !== lifecycle || !viewer.isConnected) return; const rendered = await renderPage(); if (!rendered) return; if (stage) stage.classList.remove("is-loading"); viewer.querySelectorAll("[data-pdf-action]").forEach((button) => { button.addEventListener("click", async () => { const action = button.dataset.pdfAction; if (action === "prev") pageNumber -= 1; else if (action === "next") pageNumber += 1; else if (action === "zoom-in") zoomOffset = Math.min(2.5, zoomOffset + 0.2); else if (action === "zoom-out") zoomOffset = Math.max(-1, zoomOffset - 0.2); else if (action === "reset") zoomOffset = 0; requestRender(); }); }); if (pageInput) pageInput.addEventListener("change", () => { pageNumber = Number(pageInput.value) || 1; requestRender(); }); if (stage && typeof ResizeObserver !== "undefined") { let lastWidth = stage.clientWidth; lifecycle.resizeObserver = new ResizeObserver(() => { const width = stage.clientWidth; if (Math.abs(width - lastWidth) < 2) return; lastWidth = width; cancelAnimationFrame(lifecycle.resizeFrame); lifecycle.resizeFrame = requestAnimationFrame(requestRender); }); lifecycle.resizeObserver.observe(stage); } } catch (error) { showError(error); } } async function openRandomPreview() { const requestSeq = randomRequestSeq + 1; randomRequestSeq = requestSeq; if (randomAbortController) randomAbortController.abort(); randomAbortController = new AbortController(); try { const item = await apiWithTimeout("/api/random", { signal: randomAbortController.signal }); if (requestSeq !== randomRequestSeq) return; randomAbortController = null; openPreview(item.doc_id); } catch (error) { if (error.name === "AbortError" || requestSeq !== randomRequestSeq) return; showToast("随机文章加载失败"); } } function diffHTML(leftText, rightText) { const left = splitDiffLines(leftText); const right = splitDiffLines(rightText); const maxLines = 1200; if (left.length > maxLines || right.length > maxLines) { return { left: escapeHTML(leftText), right: escapeHTML(rightText) }; } const dp = Array.from({ length: left.length + 1 }, () => new Array(right.length + 1).fill(0)); for (let i = left.length - 1; i >= 0; i -= 1) { for (let j = right.length - 1; j >= 0; j -= 1) { dp[i][j] = left[i] === right[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); } } const outLeft = []; const outRight = []; let i = 0; let j = 0; while (i < left.length || j < right.length) { if (i < left.length && j < right.length && left[i] === right[j]) { const line = `${escapeHTML(left[i])}\n`; outLeft.push(line); outRight.push(line); i += 1; j += 1; } else if (j >= right.length || (i < left.length && dp[i + 1][j] >= dp[i][j + 1])) { outLeft.push(`${escapeHTML(left[i])}\n`); outRight.push(`\n`); i += 1; } else { outLeft.push(`\n`); outRight.push(`${escapeHTML(right[j])}\n`); j += 1; } } return { left: outLeft.join(""), right: outRight.join("") }; } function splitDiffLines(text) { return String(text).split(/\n+/).map((line) => line.trim()).filter(Boolean); } function previewTitleHTML(item) { return `
${escapeHTML(item.title)}
`; } function previewMetaHTML(item) { const authors = item.authors && item.authors.length ? ` / ${escapeHTML(item.authors.join(", "))}` : ""; const tagNames = item.tag_names || []; const pageSize = 8; const totalPages = Math.ceil(tagNames.length / pageSize); const tagItems = tagNames.map((tag, index) => { const page = Math.floor(index / pageSize) + 1; const hasNextOnPage = index + 1 < tagNames.length && Math.floor((index + 1) / pageSize) + 1 === page; return ` 1 ? "hidden" : ""}>${escapeHTML(tag)}${hasNextOnPage ? ' / ' : ""}`; }).join(""); const pager = totalPages > 1 ? `
1 / ${totalPages}
` : ""; const tags = tagNames.length ? `
${tagItems}
${pager}
` : ""; return `
${escapeHTML(item.publication_name)}${authors}
${tags}
`; } function changePreviewTagPage(button) { const container = button.closest("[data-preview-tags]"); if (!container) return; const totalPages = parseInt(container.dataset.totalPages || "1", 10); const currentNode = container.querySelector("[data-preview-tag-current]"); const current = parseInt((currentNode && currentNode.textContent) || "1", 10); const next = Math.min(totalPages, Math.max(1, current + (button.dataset.previewTagPageChange === "next" ? 1 : -1))); container.querySelectorAll("[data-preview-tag-page]").forEach((tag) => { tag.hidden = parseInt(tag.dataset.previewTagPage, 10) !== next; }); if (currentNode) currentNode.textContent = String(next); const prev = container.querySelector('[data-preview-tag-page-change="prev"]'); const nextButton = container.querySelector('[data-preview-tag-page-change="next"]'); if (prev) prev.disabled = next <= 1; if (nextButton) nextButton.disabled = next >= totalPages; } function compareSelectHTML(item) { const proofread = item.article && Array.isArray(item.article.parts) ? `` : ""; if ((!item.variants || !item.variants.length) && !proofread) return ""; const options = item.variants.map((variant) => ``).join(""); return ``; } function textDelta(original, edited) { if (original === edited) return ""; if (typeof diff_match_patch !== "undefined") { const differ = new diff_match_patch(); differ.Diff_Timeout = 1; return differ.diff_main(original, edited, true).map(([operation, text]) => { if (operation === 0) return `=${text.length}`; if (operation === -1) return `-${text.length}`; return `+${text.replace(/%/g, "%25").replace(/\t/g, "%09")}`; }).join("\t"); } let prefix = 0; while (prefix < original.length && prefix < edited.length && original[prefix] === edited[prefix]) prefix += 1; let suffix = 0; while (suffix < original.length - prefix && suffix < edited.length - prefix && original[original.length - 1 - suffix] === edited[edited.length - 1 - suffix]) suffix += 1; const removedLength = original.length - prefix - suffix; const inserted = edited.slice(prefix, edited.length - suffix); return [ prefix ? `=${prefix}` : "", removedLength ? `-${removedLength}` : "", inserted ? `+${inserted.replace(/%/g, "%25").replace(/\t/g, "%09")}` : "", suffix ? `=${suffix}` : "", ].filter(Boolean).join("\t"); } function loadDiffMatchPatch() { if (typeof diff_match_patch !== "undefined") return Promise.resolve(); if (diffMatchPatchPromise) return diffMatchPatchPromise; diffMatchPatchPromise = new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = DIFF_MATCH_PATCH_URL; script.integrity = DIFF_MATCH_PATCH_INTEGRITY; script.crossOrigin = "anonymous"; script.referrerPolicy = "no-referrer"; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }).catch((error) => { diffMatchPatchPromise = null; console.warn("校订差异库加载失败", error); }); return diffMatchPatchPromise; } function applyTextDelta(original, delta) { if (!delta) return String(original); const source = String(original); let cursor = 0; let output = ""; for (const token of String(delta).split("\t")) { if (!token) continue; const operation = token[0]; const value = token.slice(1); if (operation === "=") { const size = parseInt(value, 10) || 0; output += source.slice(cursor, cursor + size); cursor += size; } else if (operation === "-") { cursor += parseInt(value, 10) || 0; } else if (operation === "+") { try { output += decodeURIComponent(value.replace(/\+/g, "%2B")); } catch (_error) { /* validated by the server */ } } } if (cursor !== source.length) throw new Error("delta length mismatch"); return output; } function partTextWithPivots(article, partIndex) { const chars = Array.from(String(article.parts[partIndex].text || "")); (article.comment_pivots || []) .filter((pivot) => Number(pivot.part_idx) === partIndex) .sort((a, b) => Number(b.offset) - Number(a.offset)) .forEach((pivot) => chars.splice(Number(pivot.offset), 0, `〔${pivot.index}〕`)); return chars.join(""); } function listEditorHTML(kind, values, fields) { const rows = values.map((value) => { const inputs = fields.map((field) => { const current = typeof value === "object" ? (value[field.key] == null ? "" : value[field.key]) : value; if (field.options) return ``; return ``; }).join(""); return `
${inputs}
`; }).join(""); return `
${rows}
`; } function proofreadHTML(item) { const article = item.article || {}; const sourceFiles = item.source_files || []; const requestedPage = Math.max(1, Number(article.page_start) || 1); const defaultFile = STATE.proofreadFile !== "" ? Math.max(0, Math.min(sourceFiles.length - 1, Number(STATE.proofreadFile) || 0)) : 0; const selectedUrl = sourceFiles[defaultFile] && sourceFiles[defaultFile].url || ""; const page = Math.max(1, Number(STATE.proofreadPage) || requestedPage); const sourcePicker = sourceFiles.length > 1 ? `` : ""; const selectedFile = sourceFiles[defaultFile] || {}; const sourceURL = sourceDownloadURL(item, defaultFile, true); const sourceDownload = sourceDownloadURL(item, defaultFile); const sourceViewer = sourceFiles.length ? (isImageSource(selectedUrl) ? `
下载原文件
原文件
` : (isPDFSource(selectedUrl) && Array.isArray(selectedFile.pages) && selectedFile.pages.length ? pageImageViewerHTML(selectedFile, page, sourceDownload) : (isPDFSource(selectedUrl) ? `
正在加载 PDF...下载原文件
` : `
此原文件暂不支持在线预览下载原文件
`))) : '
没有原文件仍可编辑正文并提交校对。
'; const partTypeOptions = (current) => PART_TYPES.map((type) => ``).join(""); const parts = (article.parts || []).map((part, index) => `
`).join(""); const comments = (article.comments || []).map((comment, index) => ``).join(""); const tags = Array.isArray(article.tags) ? article.tags : []; const metadata = `
文章元数据
`; const sourceMetadata = `
来源元数据
`; const mobileModeLabel = STATE.isMobile ? "切换到电脑模式" : "切换到手机模式"; const actions = `
`; const fulltextToggle = ``; const partToggle = ``; const fulltextBox = `
`; const fulltextSection = `
${fulltextToggle}
${fulltextBox}
`; const bodyFieldset = `
正文
${STATE.isMobile ? partToggle : fulltextToggle + partToggle}
${STATE.isMobile ? "" : fulltextBox}
`; const noteField = ``; return `
${previewTitleHTML(item)}
对比原文件并校对
${actions}
原文件${sourcePicker}
${sourceViewer}
校订内容
${STATE.isMobile ? fulltextSection : ""}${metadata}${sourceMetadata}${bodyFieldset}${noteField}
`; } function openProofread(item, focusPart = null, restoring = false) { if (!item) return; stopPreviewAnimation(); loadDiffMatchPatch(); flushProofreadDraft(); STATE.proofread = true; STATE.proofreadItem = item; STATE.compare = ""; if (!restoring) syncUrl(true); clearActiveMediaViewer(); DOM.previewPanel.classList.add("proofread-mode"); DOM.previewPanel.innerHTML = proofreadHTML(item); if (!(item.source_files || []).length) { const sourcePane = DOM.previewPanel.querySelector(".proofread-source"); if (sourcePane) sourcePane.remove(); const grid = DOM.previewPanel.querySelector(".proofread-grid"); if (grid) grid.classList.add("proofread-grid-no-source"); } const sourceImage = DOM.previewPanel.querySelector("[data-proofread-source-image]"); if (sourceImage) { const fitImage = () => { const stage = sourceImage.closest(".proofread-image-stage"); const currentSource = sourceImage.currentSrc || sourceImage.src; if (!stage || !sourceImage.naturalWidth || sourceImage.dataset.imageLoadedSrc !== currentSource) return; sourceImage.hidden = false; sourceImage.style.removeProperty("display"); const error = stage.querySelector("[data-image-error]"); if (error) error.remove(); const availableWidth = Math.max(1, stage.clientWidth - 32); const fitScale = Math.min(availableWidth / sourceImage.naturalWidth, 1); const baseWidth = Math.max(1, Math.round(sourceImage.naturalWidth * fitScale)); sourceImage.dataset.imageBaseWidth = String(baseWidth); sourceImage.style.maxWidth = "none"; sourceImage.style.width = `${baseWidth}px`; sourceImage.style.height = "auto"; }; const imageLoaded = () => { sourceImage.dataset.imageLoadedSrc = sourceImage.currentSrc || sourceImage.src; fitImage(); const pageViewer = sourceImage.closest("[data-page-image-viewer]"); if (pageViewer) prefetchAdjacentPageImage(pageViewer); }; sourceImage.addEventListener("load", imageLoaded); if (sourceImage.complete && sourceImage.naturalWidth) imageLoaded(); if (typeof ResizeObserver !== "undefined") { let lastStageWidth = 0; let lastStageHeight = 0; activeImageResizeObserver = new ResizeObserver(() => { cancelAnimationFrame(activeImageFitFrame); activeImageFitFrame = requestAnimationFrame(() => { const stage = sourceImage.closest(".proofread-image-stage"); const stageWidth = stage ? stage.clientWidth : 0; const stageHeight = stage ? stage.clientHeight : 0; if (Math.abs(stageWidth - lastStageWidth) > 2 || Math.abs(stageHeight - lastStageHeight) > 2) { lastStageWidth = stageWidth; lastStageHeight = stageHeight; fitImage(); } const scale = Number(sourceImage.dataset.imageScale || 1); const baseWidth = Number(sourceImage.dataset.imageBaseWidth) || sourceImage.naturalWidth; sourceImage.style.width = `${Math.max(1, baseWidth * scale)}px`; }); }); const stage = sourceImage.closest(".proofread-image-stage"); if (stage) activeImageResizeObserver.observe(stage); } sourceImage.addEventListener("error", () => { const stage = sourceImage.closest("[data-image-stage], .proofread-image-stage"); delete sourceImage.dataset.imageLoadedSrc; sourceImage.hidden = true; sourceImage.style.display = "none"; if (stage && !stage.querySelector("[data-image-error]")) { stage.insertAdjacentHTML("beforeend", `
图片无法在线显示下载原文件
`); } }); } const form = DOM.previewPanel.querySelector("[data-proofread-form]"); const pdfViewer = DOM.previewPanel.querySelector("[data-pdf-viewer]"); if (pdfViewer) renderPDFViewer(pdfViewer); if (form) { form.classList.add("proofread-structure-closed"); const partEditor = form.querySelector("[data-part-editor]"); if (partEditor) partEditor.hidden = false; form.querySelectorAll("[data-proofread-part-type]").forEach((select) => { select.insertAdjacentHTML("afterend", `${escapeHTML(select.value)}`); }); const structureActions = form.querySelector("fieldset .proofread-form-actions"); const descriptionInput = form.querySelector("[data-proofread-description]"); const descriptionField = descriptionInput ? descriptionInput.closest(".proofread-field") : null; const descriptionLabel = descriptionField && descriptionField.querySelector("span"); if (structureActions && descriptionLabel) { descriptionLabel.classList.add("proofread-description-heading"); descriptionLabel.append(structureActions); const bodyFieldset = descriptionField.closest("fieldset"); const bodyLegend = bodyFieldset && bodyFieldset.querySelector("legend"); if (bodyFieldset) bodyFieldset.insertBefore(descriptionField, bodyLegend ? bodyLegend.nextSibling : bodyFieldset.firstChild); } } const sourceHeader = DOM.previewPanel.querySelector(".proofread-source .proofread-pane-header"); if (sourceHeader && !sourceHeader.querySelector("[data-proofread-page]") && !DOM.previewPanel.querySelector("[data-page-image-viewer], [data-pdf-viewer]")) { sourceHeader.insertAdjacentHTML("beforeend", ``); } if (form && !form.querySelector("[data-fulltext-box]")) { const text = (item.article && item.article.parts || []).map((_part, index) => partTextWithPivots(item.article, index)).join("\n"); form.insertAdjacentHTML("afterbegin", `
`); const header = form.querySelector(".proofread-pane-header"); if (header) header.insertAdjacentHTML("afterbegin", ``); } restoreProofreadDraft(item, form); syncFulltextFromParts(form); if (!STATE.proofreadFile) { const sourceInput = DOM.previewPanel.querySelector("[data-proofread-source-file]"); STATE.proofreadFile = sourceInput ? sourceInput.value : "0"; } if (!STATE.proofreadPage) { const pageInput = form && form.querySelector("[data-proofread-page]"); STATE.proofreadPage = pageInput ? pageInput.value : String(Math.max(1, Number(item.article && item.article.page_start) || 1)); } if (!restoring) syncUrl(true); if (focusPart != null) requestAnimationFrame(() => { const input = DOM.previewPanel.querySelector(`[data-proofread-part-input="${focusPart}"]`); if (input) input.focus(); }); } function readList(form, kind) { return [...form.querySelectorAll(`[data-list="${kind}"] [data-list-row]`)].map((row) => { const values = {}; row.querySelectorAll("[data-list-field]").forEach((input) => { if (input.value !== "") values[input.dataset.listField] = input.type === "number" ? Number(input.value) : input.value.trim(); }); return kind === "authors" || kind === "files" ? (values.value || "") : values; }).filter((value) => typeof value === "string" ? Boolean(value) : Object.keys(value).length); } function proofreadDraftKey(docId) { return `bha-proofread-draft:${docId}`; } function safeSessionGet(key) { try { return sessionStorage.getItem(key); } catch (_error) { return null; } } function safeSessionSet(key, value) { try { sessionStorage.setItem(key, value); } catch (_error) { /* private browsing may disable storage */ } } function collectProofreadDraft(form) { if (!form) return null; const lists = {}; for (const kind of ["authors", "dates", "tags", "files"]) lists[kind] = readList(form, kind); return { parts: Object.fromEntries([...form.querySelectorAll("[data-proofread-part-input]")].map((input) => [input.dataset.proofreadPartInput, input.value])), comments: Object.fromEntries([...form.querySelectorAll("[data-proofread-comment]")].map((input) => [input.dataset.proofreadComment, input.value])), title: (form.querySelector("[data-meta-title]") || {}).value || "", source: Object.fromEntries(["name", "author", "type"].map((key) => { const input = form.querySelector(`[data-source-${key}]`); return [key, input ? input.value : ""]; })), lists, note: (form.querySelector("[data-proofread-note]") || {}).value || "", description: (form.querySelector("[data-proofread-description]") || {}).value || "", page: ((DOM.previewPanel && DOM.previewPanel.querySelector("[data-proofread-page], [data-pdf-page], [data-page-image-input]") || {}).value) || "", newParts: [...form.querySelectorAll("[data-proofread-new-part]")].map((row) => ({ text: (row.querySelector("textarea") || {}).value || "", type: (row.querySelector("[data-new-part-type]") || {}).value || "paragraph", group: ((row.closest("[data-proofread-part-group]") || {}).dataset || {}).proofreadPartGroup || "", before: Boolean(row.parentElement && row.parentElement.hasAttribute("data-part-insert-before")), })), deleted: Object.fromEntries([...form.querySelectorAll("[data-delete-part]")].filter((button) => button.dataset.proofreadDeleted === "true").map((button) => [button.dataset.deletePart, true])), types: Object.fromEntries([...form.querySelectorAll("[data-proofread-part-type]")].map((input) => [input.dataset.proofreadPartType, input.value])), }; } function flushProofreadDraft() { clearTimeout(proofreadDraftTimer); proofreadDraftTimer = 0; if (!STATE.preview) return; const draft = collectProofreadDraft(DOM.previewPanel.querySelector("[data-proofread-form]")); if (draft) safeSessionSet(proofreadDraftKey(STATE.preview), JSON.stringify(draft)); } function saveProofreadDraft() { clearTimeout(proofreadDraftTimer); proofreadDraftTimer = setTimeout(flushProofreadDraft, 400); } function restoreProofreadDraft(item, form) { if (!form) return; let draft; try { draft = JSON.parse(safeSessionGet(proofreadDraftKey(item.doc_id)) || "null"); } catch (_error) { draft = null; } if (!draft) return; for (const [index, value] of Object.entries(draft.parts || {})) { const input = form.querySelector(`[data-proofread-part-input="${CSS.escape(index)}"]`); if (input) input.value = value; } for (const [index, value] of Object.entries(draft.comments || {})) { const input = form.querySelector(`[data-proofread-comment="${CSS.escape(index)}"]`); if (input) input.value = value; } if (form.querySelector("[data-meta-title]")) form.querySelector("[data-meta-title]").value = draft.title || ""; for (const key of ["name", "author", "type"]) { const input = form.querySelector(`[data-source-${key}]`); if (input && draft.source) input.value = draft.source[key] || ""; } for (const kind of ["authors", "dates", "tags", "files"]) { const values = draft.lists && draft.lists[kind]; const holder = form.querySelector(`[data-list="${kind}"]`); if (!holder || !Array.isArray(values)) continue; while (holder.querySelectorAll("[data-list-row]").length < values.length) { const add = holder.querySelector(`[data-add-list="${kind}"]`); if (add) add.click(); else break; } while (holder.querySelectorAll("[data-list-row]").length > values.length) { const rows = holder.querySelectorAll("[data-list-row]"); const remove = rows[rows.length - 1].querySelector("[data-remove-list]"); if (remove) remove.click(); else break; } [...holder.querySelectorAll("[data-list-row]")].forEach((row, index) => { const value = values[index]; row.querySelectorAll("[data-list-field]").forEach((input) => { const next = typeof value === "string" ? value : value && value[input.dataset.listField]; input.value = next == null ? "" : String(next); }); }); } if (form.querySelector("[data-proofread-note]")) form.querySelector("[data-proofread-note]").value = draft.note || ""; if (form.querySelector("[data-proofread-description]")) form.querySelector("[data-proofread-description]").value = draft.description || ""; const draftPageInput = DOM.previewPanel.querySelector("[data-proofread-page], [data-pdf-page], [data-page-image-input]"); if (draft.page && draftPageInput) draftPageInput.value = draft.page; for (const [index, type] of Object.entries(draft.types || {})) { const input = form.querySelector(`[data-proofread-part-type="${CSS.escape(index)}"]`); if (input) input.value = type; } for (const index of Object.keys(draft.deleted || {})) { const button = form.querySelector(`[data-delete-part="${CSS.escape(index)}"]`); if (button) { button.dataset.proofreadDeleted = "true"; button.textContent = "恢复"; const group = button.closest("[data-proofread-part-group]"); if (group) group.classList.add("proofread-deleted"); } } for (const part of draft.newParts || []) { const target = form.querySelector(`[data-proofread-part-group="${CSS.escape(part.group)}"] [${part.before ? "data-part-insert-before" : "data-part-insert-after"}]`); if (target) { target.insertAdjacentHTML("beforeend", newPartHTML()); const row = target.lastElementChild; row.querySelector("textarea").value = part.text || ""; row.querySelector("[data-new-part-type]").value = part.type || "paragraph"; } } } function newPartHTML() { return ``; } function fullTextFromParts(form) { const lines = []; form.querySelectorAll("[data-proofread-part-group]").forEach((group) => { if (group.classList.contains("proofread-deleted")) return; group.querySelectorAll("[data-part-insert-before] > [data-proofread-new-part]").forEach((row) => { const textarea = row.querySelector("textarea"); lines.push(textarea ? textarea.value : ""); }); const partInput = group.querySelector("[data-proofread-part-input]"); if (partInput) lines.push(partInput.value); group.querySelectorAll("[data-part-insert-after] > [data-proofread-new-part]").forEach((row) => { const textarea = row.querySelector("textarea"); lines.push(textarea ? textarea.value : ""); }); }); return lines.join("\n"); } function syncFulltextFromParts(form) { const input = form && form.querySelector("[data-fulltext-input]"); if (!input || input.dataset.fulltextDirty === "1") return; input.value = fullTextFromParts(form); } function editablePartRows(form) { const rows = []; form.querySelectorAll("[data-proofread-part-group]").forEach((group) => { if (group.classList.contains("proofread-deleted")) return; group.querySelectorAll("[data-part-insert-before] > [data-proofread-new-part]").forEach((row) => rows.push(row)); const input = group.querySelector("[data-proofread-part-input]"); if (input) rows.push(input); group.querySelectorAll("[data-part-insert-after] > [data-proofread-new-part]").forEach((row) => rows.push(row)); }); return rows; } function editablePartRowText(row) { const input = row.matches("[data-proofread-new-part]") ? row.querySelector("textarea") : row; return input ? input.value : ""; } function editablePartRowType(row) { const input = row.matches("[data-proofread-new-part]") ? row.querySelector("[data-new-part-type]") : row.closest("[data-proofread-part-group]").querySelector("[data-proofread-part-type]"); return input ? input.value : "paragraph"; } function removeEditablePartRow(row) { if (row.matches("[data-proofread-new-part]")) { row.remove(); return; } const group = row.closest("[data-proofread-part-group]"); const button = group && group.querySelector("[data-delete-part]"); if (button) { button.dataset.proofreadDeleted = "true"; button.textContent = "恢复"; group.classList.add("proofread-deleted"); } } function insertEditablePartRow(form, reference, previous, text, type) { let row; if (previous && previous.matches("[data-proofread-new-part]")) { previous.insertAdjacentHTML("afterend", newPartHTML()); row = previous.nextElementSibling; } else if (previous) { const target = previous.closest("[data-proofread-part-group]").querySelector("[data-part-insert-after]"); target.insertAdjacentHTML("beforeend", newPartHTML()); row = target.lastElementChild; } else if (reference && reference.matches("[data-proofread-new-part]")) { reference.insertAdjacentHTML("beforebegin", newPartHTML()); row = reference.previousElementSibling; } else if (reference) { const target = reference.closest("[data-proofread-part-group]").querySelector("[data-part-insert-before]"); target.insertAdjacentHTML("beforeend", newPartHTML()); row = target.lastElementChild; } else { const active = editablePartRows(form); const tail = active[active.length - 1]; if (!tail) return null; if (tail.matches("[data-proofread-new-part]")) { tail.insertAdjacentHTML("afterend", newPartHTML()); row = tail.nextElementSibling; } else { const target = tail.closest("[data-proofread-part-group]").querySelector("[data-part-insert-after]"); target.insertAdjacentHTML("beforeend", newPartHTML()); row = target.lastElementChild; } } row.querySelector("textarea").value = text; row.querySelector("[data-new-part-type]").value = type || "paragraph"; return row; } function matchingLineAnchors(current, desired) { const table = Array.from({ length: current.length + 1 }, () => new Uint32Array(desired.length + 1)); for (let left = current.length - 1; left >= 0; left -= 1) { for (let right = desired.length - 1; right >= 0; right -= 1) { table[left][right] = current[left] === desired[right] ? table[left + 1][right + 1] + 1 : Math.max(table[left + 1][right], table[left][right + 1]); } } const anchors = []; let left = 0; let right = 0; while (left < current.length && right < desired.length) { if (current[left] === desired[right]) { anchors.push([left, right]); left += 1; right += 1; } else if (table[left + 1][right] >= table[left][right + 1]) { left += 1; } else { right += 1; } } return anchors; } function applyFullText(form) { const input = form.querySelector("[data-fulltext-input]"); if (!input) return; const lines = input.value.split("\n"); const rows = editablePartRows(form); if (!rows.length) return; const current = rows.map(editablePartRowText); const anchors = [...matchingLineAnchors(current, lines), [rows.length, lines.length]]; let oldStart = 0; let newStart = 0; for (const [oldEnd, newEnd] of anchors) { const paired = Math.min(oldEnd - oldStart, newEnd - newStart); for (let offset = 0; offset < paired; offset += 1) { const row = rows[oldStart + offset]; const target = row.matches("[data-proofread-new-part]") ? row.querySelector("textarea") : row; target.value = lines[newStart + offset]; } for (let index = oldStart + paired; index < oldEnd; index += 1) removeEditablePartRow(rows[index]); const reference = oldEnd < rows.length ? rows[oldEnd] : null; const inheritedType = paired ? editablePartRowType(rows[oldStart + paired - 1]) : (oldStart > 0 ? editablePartRowType(rows[oldStart - 1]) : (reference ? editablePartRowType(reference) : "paragraph")); let previous = paired ? rows[oldStart + paired - 1] : (oldStart > 0 ? rows[oldStart - 1] : null); for (let index = newStart + paired; index < newEnd; index += 1) { previous = insertEditablePartRow(form, reference, previous, lines[index], inheritedType); } oldStart = oldEnd + 1; newStart = newEnd + 1; } saveProofreadDraft(); delete input.dataset.fulltextDirty; } function canonicalize(value) { if (Array.isArray(value)) return value.map(canonicalize); if (value && typeof value === "object") { const out = {}; for (const key of Object.keys(value).sort()) { const next = canonicalize(value[key]); if (next === "" || next == null) continue; out[key] = next; } return out; } return value; } function deepSame(left, right) { return JSON.stringify(canonicalize(left)) === JSON.stringify(canonicalize(right)); } function proofreadPayload(item, form) { const article = item.article || {}; const patch = { version: 2, parts: {}, comments: {}, description: "" }; form.querySelectorAll("[data-proofread-part-input]").forEach((input) => { const index = Number(input.dataset.proofreadPartInput); const part = article.parts[index] || {}; const change = {}; const group = input.closest("[data-proofread-part-group]"); const before = group ? [...group.querySelector("[data-part-insert-before]").querySelectorAll("[data-proofread-new-part]")].map((row) => ({ text: (row.querySelector("textarea") || {}).value || "", type: (row.querySelector("[data-new-part-type]") || {}).value || "paragraph" })) : []; const after = group ? [...group.querySelector("[data-part-insert-after]").querySelectorAll("[data-proofread-new-part]")].map((row) => ({ text: (row.querySelector("textarea") || {}).value || "", type: (row.querySelector("[data-new-part-type]") || {}).value || "paragraph" })) : []; const deleted = group && group.querySelector("[data-proofread-deleted]"); if (deleted && deleted.dataset.proofreadDeleted === "true") { const deletion = { delete: true }; if (before.length) deletion.insertBefore = before; if (after.length) deletion.insertAfter = after; patch.parts[index] = deletion; return; } const delta = textDelta(partTextWithPivots(article, index), input.value); if (delta) change.diff = delta; const typeInput = form.querySelector(`[data-proofread-part-type="${index}"]`); const type = typeInput ? typeInput.value : ""; if (type && type !== (part.type || "paragraph")) change.type = type; if (before.length) change.insertBefore = before; if (after.length) change.insertAfter = after; if (Object.keys(change).length) patch.parts[index] = change; }); form.querySelectorAll("[data-proofread-comment]").forEach((input) => { const index = Number(input.dataset.proofreadComment); const delta = textDelta(String((article.comments || [])[index] || ""), input.value); if (delta) patch.comments[index + 1] = { diff: delta }; }); patch.description = textDelta(String(article.description || ""), form.querySelector("[data-proofread-description]").value); const articleMeta = { title: form.querySelector("[data-meta-title]").value.trim(), authors: readList(form, "authors"), dates: readList(form, "dates"), tags: readList(form, "tags"), }; const sourceMeta = { name: form.querySelector("[data-source-name]").value.trim(), author: form.querySelector("[data-source-author]").value.trim(), type: form.querySelector("[data-source-type]").value.trim(), files: readList(form, "files"), }; const changedPatch = Object.keys(patch.parts).length || Object.keys(patch.comments).length || patch.description; const changedArticle = !deepSame(articleMeta, { title: article.title || item.title || "", authors: article.authors || item.authors || [], dates: article.dates || [], tags: article.tags || [] }); const changedSource = !deepSame(sourceMeta, { name: item.publication_name || "", author: item.publication_author || "", type: item.publication_type || "", files: (item.source_files || []).map((file) => file.url) }); return { doc_id: item.doc_id, base_content_hash: item.content_hash || null, patch: changedPatch ? patch : null, metadata: changedArticle || changedSource ? { ...(changedArticle ? { article: articleMeta } : {}), ...(changedSource ? { source: sourceMeta } : {}) } : null, description: form.querySelector("[data-proofread-note]").value.trim(), }; } async function submitProofreadForm(form) { let item = STATE.proofreadItem || getCachedPreview(STATE.preview); if (!item) { try { item = await apiWithTimeout(`/api/preview/${encodeURIComponent(STATE.preview)}`, {}, 30000); } catch (_error) { item = null; } } if (!item) { showToast("校订内容已过期,请重新打开校对页"); return; } const payload = proofreadPayload(item, form); flushProofreadDraft(); if (!payload.patch && !payload.metadata) { showToast("没有需要提交的修改"); return; } showProofreadConfirm(item, payload); } function removeProofreadConfirm() { const panel = document.querySelector("[data-proofread-confirm]"); if (panel) panel.remove(); } function showProofreadConfirm(item, payload) { removeProofreadConfirm(); const holder = document.createElement("div"); holder.className = "proofread-confirm"; holder.dataset.proofreadConfirm = "true"; holder.innerHTML = ``; document.body.append(holder); holder.querySelectorAll("[data-cancel-proofread]").forEach((button) => { button.addEventListener("click", removeProofreadConfirm); }); holder.querySelector("[data-confirm-proofread]").addEventListener("click", async () => { const confirmButton = holder.querySelector("[data-confirm-proofread]"); confirmButton.disabled = true; confirmButton.textContent = "提交中..."; await postProofread(payload); confirmButton.disabled = false; confirmButton.textContent = "确认提交"; removeProofreadConfirm(); }); } function metadataItemText(field, value) { if (value == null) return ""; if (typeof value === "string") return value; if (field === "dates") { const parts = []; for (const [key, suffix] of [["year", "年"], ["month", "月"], ["day", "日"]]) { if (value[key] != null) parts.push(`${value[key]}${suffix}`); } return parts.join("") || JSON.stringify(value); } if (field === "tags") { const name = value.name || ""; return value.type ? `${name}(${value.type})` : name; } return JSON.stringify(value); } function metadataCellHTML(field, value) { const listFields = { authors: 1, dates: 1, tags: 1, files: 1 }; if (listFields[field]) { const items = Array.isArray(value) ? value : []; if (!items.length) return '(空)'; return items.map((item) => `
${escapeHTML(metadataItemText(field, item))}
`).join(""); } const text = value == null || value === "" ? "" : String(value); return text ? escapeHTML(text) : '(空)'; } function proofreadReviewHTML(item, payload) { const article = item.article || {}; const rows = []; const patch = payload.patch || {}; for (const [rawIndex, change] of Object.entries(patch.parts || {})) { const index = Number(rawIndex); const original = partTextWithPivots(article, index); const pieces = []; if (change.delete) pieces.push(`
${escapeHTML(original)}
`); if (change.diff) { const edited = applyTextDelta(original, change.diff); const diff = diffHTML(original, edited); pieces.push(`
${diff.left}
${diff.right}
`); } for (const key of ["insertBefore", "insertAfter"]) { for (const part of Array.isArray(change[key]) ? change[key] : []) { pieces.push(`
${escapeHTML(part.type)}${escapeHTML(part.text)}
`); } } if (change.type) { const previous = article.parts[index] && article.parts[index].type || "paragraph"; pieces.push(`
段落类型:${escapeHTML(previous)} → ${escapeHTML(change.type)}
`); } if (pieces.length) rows.push(`
段落 ${index + 1}${pieces.join("")}
`); } for (const [rawIndex, change] of Object.entries(patch.comments || {})) { const index = Number(rawIndex) - 1; const original = String((article.comments || [])[index] || ""); const pieces = []; if (change.delete) pieces.push(`
${escapeHTML(original)}
`); if (change.diff) { const edited = applyTextDelta(original, change.diff); const diff = diffHTML(original, edited); pieces.push(`
${diff.left}
${diff.right}
`); } for (const key of ["insertBefore", "insertAfter"]) { for (const comment of Array.isArray(change[key]) ? change[key] : []) { pieces.push(`
注释${escapeHTML(comment.text || "")}
`); } } if (pieces.length) rows.push(`
注释 ${index + 1}${pieces.join("")}
`); } if (patch.description) { const original = String(article.description || ""); const edited = applyTextDelta(original, patch.description); const diff = diffHTML(original, edited); rows.push(`
正文说明
${diff.left}
${diff.right}
`); } const fieldLabels = { title: "标题", authors: "作者", dates: "日期", tags: "标签", name: "来源名称", author: "来源作者", type: "来源类型", files: "来源文件", }; const metadata = payload.metadata || {}; const articleOld = { title: article.title || item.title || "", authors: article.authors || item.authors || [], dates: article.dates || [], tags: article.tags || [] }; const sourceOld = { name: item.publication_name || "", author: item.publication_author || "", type: item.publication_type || "", files: (item.source_files || []).map((file) => file.url) }; const metadataRows = []; for (const [group, values] of [["文章", metadata.article], ["来源", metadata.source]]) { if (!values) continue; const oldValues = group === "文章" ? articleOld : sourceOld; for (const [key, value] of Object.entries(values)) { if (deepSame(value, oldValues[key])) continue; metadataRows.push(`${escapeHTML(group)}${escapeHTML(fieldLabels[key] || key)}${metadataCellHTML(key, oldValues[key])}${metadataCellHTML(key, value)}`); } } if (metadataRows.length) { rows.push(`
元数据对照${metadataRows.join("")}
字段原元数据修改后元数据
`); } if (payload.description) { rows.push(`
校订备注
${escapeHTML(payload.description)}
`); } if (!rows.length) return '
没有可显示的变更
'; return rows.join(""); } async function postProofread(payload) { try { const result = await apiWithTimeout("/api/proofread", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }, 30000); try { sessionStorage.removeItem(proofreadDraftKey(payload.doc_id)); } catch (_error) { /* storage is optional */ } showToast(result.auto_merge ? "校订已提交,通过检查后自动合并" : "校订已提交,等待人工审核(合并对应 PR 后自动完成)"); } catch (error) { showToast(`校订提交失败:${error && error.message ? error.message : "未知错误"}`); } } async function comparePreview(docId, restoring = false) { if (!STATE.preview || !docId) return; stopPreviewAnimation(); const baseDocId = STATE.preview; STATE.compare = docId; STATE.proofread = false; if (!restoring) syncUrl(true); const requestSeq = compareRequestSeq + 1; compareRequestSeq = requestSeq; try { const currentCached = getCachedPreview(baseDocId); const targetCached = getCachedPreview(docId); if (!currentCached || !targetCached) { if (compareAbortController) compareAbortController.abort(); compareAbortController = new AbortController(); } const [current, target] = await Promise.all([ currentCached || apiWithTimeout(`/api/preview/${encodeURIComponent(baseDocId)}`, { signal: compareAbortController.signal }), targetCached || apiWithTimeout(`/api/preview/${encodeURIComponent(docId)}`, { signal: compareAbortController.signal }), ]); if (requestSeq !== compareRequestSeq || STATE.preview !== baseDocId) return; if (!currentCached) setCachedPreview(baseDocId, current); if (!targetCached) setCachedPreview(docId, target); compareAbortController = null; clearActiveMediaViewer(); DOM.previewPanel.classList.remove("proofread-mode"); DOM.previewPanel.innerHTML = previewHTML(current, target); } catch (error) { if (error.name === "AbortError" || requestSeq !== compareRequestSeq) return; showToast("对比加载失败"); } } function resetFilters() { STATE.sources = []; STATE.authors = []; STATE.tags = []; STATE.excludeSources = []; STATE.excludeAuthors = []; STATE.excludeTags = []; STATE.archiveId = ""; STATE.publicationType = ""; clearDateFilter(); STATE.page = 1; renderFilterOptions({ preserveScroll: true }); applyStateToDom(); doSearch(); } function attachEvents() { DOM.searchForm.addEventListener("submit", (event) => { event.preventDefault(); STATE.parseMode = false; STATE.q = DOM.searchInput.value.trim(); STATE.page = 1; doSearch(); }); DOM.parseBtn.addEventListener("click", () => { flushParseDraft(); STATE.parseMode = !STATE.parseMode; if (STATE.parseMode) { STATE.leftOpen = !STATE.isMobile; STATE.sidebarExpanded = false; } STATE.parsePage = 1; STATE.parseItem = null; STATE.preview = ""; clearPreviewDom(); applyStateToDom(); syncUrl(false); if (STATE.parseMode) loadParseQueue(); else doSearch(); }); DOM.searchInput.addEventListener("input", scheduleSearch); DOM.searchInput.addEventListener("compositionstart", () => { searchComposing = true; }); DOM.searchInput.addEventListener("compositionend", () => { searchComposing = false; scheduleSearch(); }); DOM.leftToggle.addEventListener("click", () => { STATE.leftOpen = !STATE.leftOpen; if (!STATE.leftOpen) STATE.sidebarExpanded = false; applyStateToDom(); syncUrl(); }); DOM.overlay.addEventListener("click", () => { STATE.leftOpen = false; STATE.sidebarExpanded = false; applyStateToDom(); syncUrl(); }); DOM.themeBtn.addEventListener("click", toggleTheme); DOM.mobileToggleBtn.addEventListener("click", toggleMobileMode); DOM.sidebarExpand.addEventListener("click", () => { STATE.sidebarExpanded = !STATE.sidebarExpanded; applyStateToDom(); syncUrl(); }); DOM.exactToggle.addEventListener("change", () => { STATE.exact = DOM.exactToggle.checked; STATE.page = 1; doSearch(); }); DOM.previewHighlightToggle.addEventListener("change", () => { STATE.previewHighlight = DOM.previewHighlightToggle.checked; syncUrl(); if (STATE.preview) restorePreview(false); }); DOM.fulltextToggle.addEventListener("change", () => { STATE.fulltext = DOM.fulltextToggle.checked; STATE.page = 1; doSearch(); }); DOM.pageSize.addEventListener("change", () => { if (STATE.parseMode) { STATE.parsePageSize = parseInt(DOM.pageSize.value, 10); STATE.parsePage = 1; syncUrl(); loadParseQueue(); return; } STATE.pageSize = parseInt(DOM.pageSize.value, 10); STATE.page = 1; doSearch(); }); DOM.sortFilter.addEventListener("change", () => { STATE.sort = DOM.sortFilter.value; STATE.page = 1; doSearch(); }); DOM.sourceSearch.addEventListener("input", () => { STATE.filterSearch.source = DOM.sourceSearch.value; STATE.filterPage.source = 1; scheduleFacetSearch("source"); }); DOM.authorSearch.addEventListener("input", () => { STATE.filterSearch.author = DOM.authorSearch.value; STATE.filterPage.author = 1; scheduleFacetSearch("author"); }); DOM.tagSearch.addEventListener("input", () => { STATE.filterSearch.tag = DOM.tagSearch.value; STATE.filterPage.tag = 1; scheduleFacetSearch("tag"); }); DOM.sourceFilter.addEventListener("click", (event) => { const pageButton = event.target.closest("[data-source-page]"); if (pageButton) { handleFilterPageButton("source", DOM.sourceFilter, pageButton); return; } if (event.target.closest("[data-clear-source]")) { clearFilterKind("source"); return; } if (event.target.closest("[data-cycle-source-page]")) { cycleFilterPage("source"); return; } const item = event.target.closest("[data-source]"); if (!item || event.target.closest("input")) return; cycleFilterValue("source", item.dataset.source); }); DOM.authorFilter.addEventListener("click", (event) => { const pageButton = event.target.closest("[data-author-page]"); if (pageButton) { handleFilterPageButton("author", DOM.authorFilter, pageButton); return; } if (event.target.closest("[data-clear-author]")) { clearFilterKind("author"); return; } if (event.target.closest("[data-cycle-author-page]")) { cycleFilterPage("author"); return; } const item = event.target.closest("[data-author]"); if (!item || event.target.closest("input")) return; cycleFilterValue("author", item.dataset.author); }); DOM.tagFilter.addEventListener("click", (event) => { const pageButton = event.target.closest("[data-tag-page]"); if (pageButton) { handleFilterPageButton("tag", DOM.tagFilter, pageButton); return; } if (event.target.closest("[data-clear-tag]")) { clearFilterKind("tag"); return; } if (event.target.closest("[data-cycle-tag-page]")) { cycleFilterPage("tag"); return; } const item = event.target.closest("[data-tag]"); if (!item || event.target.closest("input")) return; cycleFilterValue("tag", item.dataset.tag); }); for (const filter of [DOM.sourceFilter, DOM.authorFilter, DOM.tagFilter]) { filter.addEventListener("keydown", (event) => { const item = event.target.closest(".filter-choice-item"); if (!item || (event.key !== "Enter" && event.key !== " ")) return; event.preventDefault(); item.click(); }); } for (const [kind, element] of Object.entries({ source: DOM.sourceFilter, author: DOM.authorFilter, tag: DOM.tagFilter })) { element.addEventListener("keydown", (event) => { const input = event.target.closest(`[data-${kind}-page-input]`); if (!input || event.key !== "Enter") return; event.preventDefault(); commitFilterPage(kind, input); input.blur(); }); element.addEventListener("focusout", (event) => { const input = event.target.closest(`[data-${kind}-page-input]`); if (input) restoreFilterPageInput(kind, input); }); } DOM.archiveFilter.addEventListener("change", () => { STATE.archiveId = DOM.archiveFilter.value; STATE.page = 1; doSearch(); }); DOM.typeFilter.addEventListener("change", () => { STATE.publicationType = DOM.typeFilter.value; STATE.page = 1; doSearch(); }); DOM.periodPresets.addEventListener("click", (event) => { const button = event.target.closest("[data-period-from]"); if (!button) return; const isActive = dateKey("from") === (button.dataset.periodFrom || "") && dateKey("to") === (button.dataset.periodTo || ""); if (isActive) { setDateParts("from", ""); setDateParts("to", ""); STATE.page = 1; renderDateControls(); doSearch(); return; } setDateParts("from", button.dataset.periodFrom || ""); setDateParts("to", button.dataset.periodTo || ""); STATE.page = 1; renderDateControls(); doSearch(); }); DOM.dateClear.addEventListener("click", () => { clearDateFilter(); STATE.page = 1; renderDateControls(); doSearch(); }); DOM.dateTermAdd.addEventListener("click", () => { STATE.dateTerms.push({ year: "", month: "", day: "" }); renderDateControls(); syncUrl(); }); DOM.dateTerms.addEventListener("click", (event) => { const remove = event.target.closest("[data-date-term-remove]"); if (!remove) return; STATE.dateTerms.splice(parseInt(remove.dataset.dateTermRemove, 10), 1); if (!STATE.dateTerms.length) STATE.dateTerms.push({ year: "", month: "", day: "" }); STATE.page = 1; renderDateControls(); doSearch(); }); const updateDateTerm = (event) => { const select = event.target.closest("[data-date-term-field]"); if (!select) return; const row = select.closest("[data-date-term-index]"); const index = parseInt((row && row.dataset.dateTermIndex) || "-1", 10); if (!STATE.dateTerms[index]) return; STATE.dateTerms[index][select.dataset.dateTermField] = select.value; if (select.dataset.dateTermField === "year") STATE.dateTerms[index].year = cleanYear(select.value); STATE.page = 1; renderDateControls(); doSearch(); }; DOM.dateTerms.addEventListener("change", updateDateTerm); document.addEventListener("focusin", (event) => { const input = yearInputFromTarget(event.target); if (input) showYearPicker(input); }); document.addEventListener("input", (event) => { const input = yearInputFromTarget(event.target); if (input) showYearPicker(input); }); DOM.yearPicker.addEventListener("pointerdown", (event) => { const option = event.target.closest ? event.target.closest("[data-year-value]") : null; if (!option || !activeYearInput) return; yearPickerPointer = { option, x: event.clientX, y: event.clientY }; event.stopPropagation(); }); DOM.yearPicker.addEventListener("pointerup", (event) => { const option = event.target.closest ? event.target.closest("[data-year-value]") : null; if (!option || !activeYearInput || !yearPickerPointer || yearPickerPointer.option !== option) return; const moved = Math.abs(event.clientX - yearPickerPointer.x) > 8 || Math.abs(event.clientY - yearPickerPointer.y) > 8; yearPickerPointer = null; if (!moved) chooseYear(activeYearInput, option.dataset.yearValue || ""); }); DOM.yearPicker.addEventListener("pointercancel", () => { yearPickerPointer = null; }); document.addEventListener("click", (event) => { if ((!event.target.closest || !event.target.closest(".year-input")) && (!event.target.closest || !event.target.closest("#year-picker"))) hideYearPicker(); }); document.addEventListener("keydown", (event) => { if (event.key === "Escape" && !DOM.yearPicker.hidden) hideYearPicker(); }); window.addEventListener("scroll", handleYearPickerScroll, true); window.addEventListener("resize", repositionYearPicker); window.addEventListener("resize", () => schedulePageRecovery(120)); DOM.searchInput.addEventListener("focus", () => warmConnection()); window.addEventListener("focus", () => { schedulePageRecovery(0); warmConnection(); }); window.addEventListener("pageshow", () => schedulePageRecovery(0)); window.addEventListener("pagehide", (event) => { flushParseDraft(); flushProofreadDraft(); if (event.persisted) return; clearActiveMediaViewer(); cleanupPDFDocumentCache(true); }); window.addEventListener("online", () => warmConnection(true)); document.addEventListener("visibilitychange", () => { if (document.hidden) { flushParseDraft(); flushProofreadDraft(); } else { schedulePageRecovery(0); warmConnection(true); } }); for (const [element, stateKey] of [ [DOM.dateFromYear, "dateFromYear"], [DOM.dateFromMonth, "dateFromMonth"], [DOM.dateFromDay, "dateFromDay"], [DOM.dateToYear, "dateToYear"], [DOM.dateToMonth, "dateToMonth"], [DOM.dateToDay, "dateToDay"], ]) { element.addEventListener("change", () => { STATE[stateKey] = element.value; if (stateKey.endsWith("Year")) STATE[stateKey] = cleanYear(element.value); if (stateKey.endsWith("Year") && !STATE[stateKey]) { const prefix = stateKey.startsWith("dateFrom") ? "dateFrom" : "dateTo"; STATE[`${prefix}Month`] = ""; STATE[`${prefix}Day`] = ""; } if (stateKey.endsWith("Month")) { const prefix = stateKey.startsWith("dateFrom") ? "dateFrom" : "dateTo"; const day = STATE[`${prefix}Day`]; const year = STATE[`${prefix}Year`]; if (!element.value) STATE[`${prefix}Day`] = ""; if (day && year && element.value && Number(day) > daysInMonth(year, element.value)) STATE[`${prefix}Day`] = ""; } STATE.page = 1; renderDateControls(); doSearch(); }); } DOM.clearFilters.addEventListener("click", resetFilters); DOM.randomBookBtn.addEventListener("click", openRandomPreview); DOM.emptyRandomBtn.addEventListener("click", openRandomPreview); document.querySelectorAll("[data-filter-toggle]").forEach((toggle) => { toggle.addEventListener("click", () => { const key = toggle.dataset.filterToggle; STATE.filterCollapsed[key] = !STATE.filterCollapsed[key]; applyStateToDom(); }); }); DOM.results.addEventListener("click", (event) => { const parse = event.target.closest("[data-parse-open]"); if (parse) { openParseEditor(parse.dataset.parseOpen); return; } const preview = event.target.closest("[data-preview]"); if (preview) { openPreview(preview.dataset.preview); return; } const source = event.target.closest(".path-folder[data-source]"); if (source) { toggleResultFilter("source", source.dataset.source); return; } const author = event.target.closest(".path-folder[data-author]"); if (author) { toggleResultFilter("author", author.dataset.author); return; } const tag = event.target.closest(".path-folder[data-tag]"); if (tag) { toggleResultFilter("tag", tag.dataset.tag); return; } const dateTermKey = event.target.closest(".path-folder[data-date-term-key]"); if (dateTermKey) { toggleResultDateTerm(dateTermKey.dataset.dateTermLabel || dateTermKey.textContent || ""); return; } }); DOM.previewPanel.addEventListener("click", (event) => { if (event.target.closest("[data-parse-back], [data-parse-close]")) { flushParseDraft(); STATE.parseItem = null; STATE.preview = ""; clearPreviewDom(); applyStateToDom(); renderParseQueue(); return; } const cropOpen = event.target.closest("[data-parse-crop-open]"); if (cropOpen) { openParseCrop(cropOpen.closest("[data-parse-article]")); return; } if (event.target.closest("[data-parse-crop-toggle]")) { const toolbar = DOM.previewPanel.querySelector("[data-parse-crop-toolbar]"); if (toolbar && toolbar.hidden) openParseCrop(); else closeParseCrop(); return; } const cropScope = event.target.closest("[data-parse-crop-scope]"); if (cropScope) { setParseCropScope(cropScope.dataset.parseCropScope); return; } if (event.target.closest("[data-parse-crop-reset]")) { const row = parseCropTargetRow(); setParseCropThresholds(row, parseCropScope(), STATE.parseCurrentPage, [0, 0, 0, 0], true); updateParseCropUI(); updateParsePageOCR(); finishParseCropChange(row); return; } if (event.target.closest("[data-parse-crop-close]")) { closeParseCrop(); return; } if (event.target.closest("[data-parse-page-prev]")) { loadParsePage(STATE.parseCurrentPage - 1); saveParseDraft(); return; } if (event.target.closest("[data-parse-page-next]")) { loadParsePage(STATE.parseCurrentPage + 1); saveParseDraft(); return; } const addArticle = event.target.closest("[data-parse-add]"); if (addArticle) { const form = addArticle.closest("[data-parse-form]"); const holder = form.querySelector("[data-parse-articles]"); const index = holder.querySelectorAll("[data-parse-article]").length; const current = Math.min(Number(form.dataset.maxPage) || 1, Math.max(1, STATE.parseCurrentPage)); holder.insertAdjacentHTML("beforeend", parseArticleRowHTML({ title: "", authors: [], dates: (STATE.parseItem.article || {}).dates || [], page_start: current, page_end: current }, index, Number(form.dataset.maxPage) || 1)); refreshParseArticleBody(holder.lastElementChild, false, true); saveParseDraft(); return; } const refreshBody = event.target.closest("[data-parse-refresh-body]"); if (refreshBody) { const row = refreshBody.closest("[data-parse-article]"); const textarea = row && row.querySelector("[data-parse-content]"); if (textarea && textarea.dataset.edited === "1" && !window.confirm("确认用当前页码范围的 OCR 覆盖已编辑正文?")) return; refreshParseArticleBody(row, true, true); return; } const removeArticle = event.target.closest("[data-parse-remove]"); if (removeArticle && !removeArticle.disabled) { const row = removeArticle.closest("[data-parse-article]"); const wasCropTarget = row && row.classList.contains("is-crop-target"); if (row) row.remove(); DOM.previewPanel.querySelectorAll("[data-parse-article] legend").forEach((legend, index) => { legend.textContent = `文章 ${index + 1}`; }); if (wasCropTarget) setParseCropTarget(parseCropTargetRow()); saveParseDraft(); return; } const partInsert = event.target.closest("[data-add-part-before], [data-add-part-after]"); if (partInsert) { const group = partInsert.closest("[data-proofread-part-group]"); const target = group && group.querySelector(partInsert.dataset.addPartBefore != null ? "[data-part-insert-before]" : "[data-part-insert-after]"); if (target) target.insertAdjacentHTML("beforeend", newPartHTML()); syncFulltextFromParts(partInsert.closest("[data-proofread-form]")); saveProofreadDraft(); return; } const deletePart = event.target.closest("[data-delete-part]"); if (deletePart) { deletePart.dataset.proofreadDeleted = deletePart.dataset.proofreadDeleted === "true" ? "false" : "true"; deletePart.textContent = deletePart.dataset.proofreadDeleted === "true" ? "恢复" : "删除"; const group = deletePart.closest("[data-proofread-part-group]"); if (group) group.classList.toggle("proofread-deleted", deletePart.dataset.proofreadDeleted === "true"); syncFulltextFromParts(deletePart.closest("[data-proofread-form]")); saveProofreadDraft(); return; } if (event.target.closest("[data-remove-new-part]")) { const newPart = event.target.closest("[data-proofread-new-part]"); if (newPart) newPart.remove(); syncFulltextFromParts(event.target.closest("[data-proofread-form]")); saveProofreadDraft(); return; } if (event.target.closest("[data-fulltext-toggle]")) { const box = DOM.previewPanel.querySelector("[data-fulltext-box]"); const button = event.target.closest("[data-fulltext-toggle]"); if (box) { const opening = box.hidden; if (opening) { const form = DOM.previewPanel.querySelector("[data-proofread-form]"); const input = box.querySelector("[data-fulltext-input]"); if (form && input && input.dataset.fulltextDirty !== "1") input.value = fullTextFromParts(form); } box.hidden = !box.hidden; button.textContent = box.hidden ? "全文编辑" : "收起全文编辑"; } return; } const partEditorToggle = event.target.closest("[data-part-editor-toggle]"); if (partEditorToggle) { const form = DOM.previewPanel.querySelector("[data-proofread-form]"); if (form) { const open = form.classList.toggle("proofread-structure-open"); form.classList.toggle("proofread-structure-closed", !open); partEditorToggle.textContent = open ? "收起段落编辑" : "段落编辑"; form.querySelectorAll("[data-proofread-part-type]").forEach((select) => { const label = select.nextElementSibling; if (label && label.classList.contains("proofread-part-type-label")) label.textContent = select.value; }); } return; } const zoomButton = event.target.closest("[data-image-zoom]"); if (zoomButton) { const image = DOM.previewPanel.querySelector("[data-proofread-source-image]"); if (!image) return; const current = Number(image.dataset.imageScale || 1); const next = zoomButton.dataset.imageZoom === "reset" ? 1 : Math.min(4, Math.max(0.25, current + (zoomButton.dataset.imageZoom === "in" ? 0.25 : -0.25))); image.dataset.imageScale = String(next); const baseWidth = Number(image.dataset.imageBaseWidth) || image.naturalWidth; image.style.maxWidth = "none"; image.style.width = `${Math.max(1, baseWidth * next)}px`; image.style.height = "auto"; image.style.transform = "none"; return; } const pageButton = event.target.closest("[data-page-image-prev], [data-page-image-next]"); if (pageButton) { const viewer = pageButton.closest("[data-page-image-viewer]"); if (viewer) { const current = Number(viewer.dataset.pageIndex) || 0; setPageImage(viewer, current + (pageButton.dataset.pageImagePrev != null ? 0 : 2)); } return; } if (event.target.closest("[data-fulltext-apply]")) { applyFullText(event.target.closest("[data-proofread-form]")); return; } const addList = event.target.closest("[data-add-list]"); if (addList) { const kind = addList.dataset.addList; const fields = kind === "dates" ? [{ key: "year", label: "年", type: "number" }, { key: "month", label: "月", type: "number" }, { key: "day", label: "日", type: "number" }] : kind === "tags" ? [{ key: "name", label: "名称" }, { key: "type", label: "类型", options: TAG_TYPES }] : [{ key: "value", label: kind === "authors" ? "作者" : "URL" }]; const holder = document.createElement("div"); holder.innerHTML = listEditorHTML(kind, [kind === "dates" || kind === "tags" ? {} : ""], fields); const row = holder.querySelector("[data-list-row]"); addList.before(row); const firstInput = row.querySelector("input"); if (firstInput) firstInput.focus(); saveProofreadDraft(); return; } const removeList = event.target.closest("[data-remove-list]"); if (removeList) { const row = removeList.closest("[data-list-row]"); if (row) row.remove(); saveProofreadDraft(); return; } const tagPageButton = event.target.closest("[data-preview-tag-page-change]"); if (tagPageButton) { changePreviewTagPage(tagPageButton); return; } if (event.target.closest("[data-close-preview]")) { closePreview(true); return; } if (event.target.closest("[data-proofread-back]")) { const item = getCachedPreview(STATE.preview); if (item) openPreview(item.doc_id, false); return; } if (event.target.closest("[data-preview-theme]")) toggleTheme(); if (event.target.closest("[data-preview-mobile]")) toggleMobileMode(); }); DOM.previewPanel.addEventListener("pointerdown", (event) => { if (event.target.closest("[data-parse-crop-overlay]")) beginParseCropDrag(event); }); window.addEventListener("pointermove", moveParseCrop, { passive: false }); window.addEventListener("pointerup", endParseCrop); window.addEventListener("pointercancel", endParseCrop); DOM.previewPanel.addEventListener("change", (event) => { const parsePage = event.target.closest("[data-parse-page]"); if (parsePage) { loadParsePage(parsePage.value); saveParseDraft(); return; } const parseRange = event.target.closest("[data-parse-start], [data-parse-end]"); if (parseRange) { const row = parseRange.closest("[data-parse-article]"); refreshParseArticleBody(row, false, true); if (row && row.classList.contains("is-crop-target")) updateParseCropUI(); saveParseDraft(); return; } const select = event.target.closest("[data-compare-select]"); if (select && select.value) { if (select.value === "__proofread__") openProofread(getCachedPreview(STATE.preview)); else comparePreview(select.value); return; } const sourceFile = event.target.closest("[data-proofread-source-file]"); if (sourceFile) { STATE.proofreadFile = sourceFile.value; const item = getCachedPreview(STATE.preview); openProofread(item, null, true); syncUrl(true); saveProofreadDraft(); return; } const pageInput = event.target.closest("[data-proofread-page]"); if (pageInput) { STATE.proofreadPage = String(Math.max(1, Number(pageInput.value) || 1)); syncUrl(true); saveProofreadDraft(); return; } const pageImageInput = event.target.closest("[data-page-image-input]"); if (pageImageInput) { const viewer = pageImageInput.closest("[data-page-image-viewer]"); if (viewer) setPageImage(viewer, Number(pageImageInput.value) || 1); return; } const form = event.target.closest("[data-proofread-form]"); if (form) { if (!event.target.matches("[data-fulltext-input]")) syncFulltextFromParts(form); saveProofreadDraft(); } }); DOM.previewPanel.addEventListener("input", (event) => { if (event.target.matches("[data-parse-content]")) event.target.dataset.edited = "1"; if (event.target.closest("[data-parse-form]")) saveParseDraft(); if (event.target.matches("[data-fulltext-input]")) event.target.dataset.fulltextDirty = "1"; const form = event.target.closest("[data-proofread-form]"); if (form) { if (!event.target.matches("[data-fulltext-input]")) syncFulltextFromParts(form); saveProofreadDraft(); } }); DOM.previewPanel.addEventListener("submit", (event) => { const parseForm = event.target.closest("[data-parse-form]"); if (parseForm) { event.preventDefault(); submitParseForm(parseForm); return; } const form = event.target.closest("[data-proofread-form]"); if (!form) return; event.preventDefault(); submitProofreadForm(form); }); DOM.previewPanel.addEventListener("mouseup", (event) => { if (!event.target.closest("[data-proofread-select]")) return; const selection = window.getSelection(); const selected = String(selection ? selection.toString() : "").trim(); const item = getCachedPreview(STATE.preview); if (!selected || !item || !item.article || !item.article.parts) return; const partIndex = item.article.parts.findIndex((_part, index) => partTextWithPivots(item.article, index).includes(selected)); openProofread(item, partIndex >= 0 ? partIndex : null); }); DOM.prevPage.addEventListener("click", () => { if (STATE.parseMode) { if (STATE.parsePage > 1) { STATE.parsePage -= 1; syncUrl(); loadParseQueue(); } return; } DOM.pageInput.value = String(STATE.page); if (STATE.page > 1) { STATE.page -= 1; doSearch(); } }); DOM.nextPage.addEventListener("click", () => { if (STATE.parseMode) { if (STATE.parsePage < Math.ceil(STATE.parseTotal / STATE.parsePageSize)) { STATE.parsePage += 1; syncUrl(); loadParseQueue(); } return; } DOM.pageInput.value = String(STATE.page); if (STATE.page < Math.ceil(STATE.total / STATE.pageSize)) { STATE.page += 1; doSearch(); } }); DOM.pageInput.addEventListener("keydown", (event) => { if (event.key !== "Enter") return; event.preventDefault(); if (STATE.parseMode) { const totalPages = Math.max(1, Math.ceil(STATE.parseTotal / STATE.parsePageSize)); STATE.parsePage = Math.min(totalPages, Math.max(1, Number(DOM.pageInput.value) || 1)); syncUrl(); loadParseQueue(); } else commitResultPage(); DOM.pageInput.blur(); }); DOM.pageInput.addEventListener("blur", () => { DOM.pageInput.value = String(STATE.parseMode ? STATE.parsePage : STATE.page); }); window.addEventListener("popstate", () => { cancelPreviewRequests(); const wasPreviewOpen = Boolean(STATE.preview); parseUrl(); if (wasPreviewOpen && !STATE.preview) { history.replaceState({ previewEntry: false }, "", previewCloseUrl); parseUrl(); } applyStateToDom(); if (STATE.parseMode) { loadParseQueue(); return; } restorePreview(false); if (wasPreviewOpen && !STATE.preview) { restoreResultsScroll(previewResultsScrollTop); } else { doSearch(); } }); document.addEventListener("keydown", (event) => { if (event.key === "/" && document.activeElement !== DOM.searchInput) { event.preventDefault(); DOM.searchInput.focus(); DOM.searchInput.select(); } }); } function cacheDom() { Object.assign(DOM, { leftSidebar: $("#left-sidebar"), sidebarTitle: $("#sidebar-title"), overlay: $("#overlay"), sourceSearch: $("#source-search"), sourceFilter: $("#source-filter"), searchForm: $("#search-form"), searchInput: $("#search-input"), leftToggle: $("#left-toggle"), mobileToggleBtn: $("#mobile-toggle-btn"), mobileToggleIcon: $("#mobile-toggle-icon"), themeBtn: $("#theme-btn"), parseBtn: $("#parse-btn"), sidebarExpand: $("#sidebar-expand"), exactToggle: $("#exact-toggle"), previewHighlightToggle: $("#preview-highlight-toggle"), fulltextToggle: $("#fulltext-toggle"), pageSize: $("#page-size"), parsePageSizeOption: $("#parse-page-size-option"), sortFilter: $("#sort-filter"), authorSearch: $("#author-search"), authorFilter: $("#author-filter"), tagSearch: $("#tag-search"), tagFilter: $("#tag-filter"), archiveFilter: $("#archive-filter"), typeFilter: $("#type-filter"), periodPresets: $("#period-presets"), dateFromYear: $("#date-from-year"), dateFromMonth: $("#date-from-month"), dateFromDay: $("#date-from-day"), dateToYear: $("#date-to-year"), dateToMonth: $("#date-to-month"), dateToDay: $("#date-to-day"), dateClear: $("#date-clear"), dateError: $("#date-error"), yearPicker: $("#year-picker"), dateTermAdd: $("#date-term-add"), dateTerms: $("#date-terms"), clearFilters: $("#clear-filters"), resultCount: $("#result-count"), statusText: $("#status-text"), indexProgress: $("#index-progress"), results: $("#results"), emptyState: $("#empty-state"), randomBookBtn: $("#random-book-btn"), emptyRandomBtn: $("#empty-random-btn"), previewPanel: $("#preview-panel"), prevPage: $("#prev-page"), nextPage: $("#next-page"), pageInput: $("#page-input"), pageTotal: $("#page-total"), toast: $("#toast"), }); } function init() { document.body.classList.toggle("firefox", /Firefox\//.test(navigator.userAgent)); if ("serviceWorker" in navigator) navigator.serviceWorker.register("/static/sw.js", { scope: "/" }).catch(() => {}); if ("scrollRestoration" in history) history.scrollRestoration = "manual"; cacheDom(); STATE.isDark = localStorage.getItem("theme") !== "light"; const savedMobileMode = localStorage.getItem("mobileMode"); STATE.isMobile = savedMobileMode === "mobile" || (!savedMobileMode && window.innerWidth <= 768); parseUrl(); if (STATE.preview && !(history.state && history.state.previewEntry === true)) { const previewDocId = STATE.preview; STATE.preview = ""; syncUrl(); STATE.preview = previewDocId; syncUrl(false); } applyStateToDom(); attachEvents(); const initialSearch = STATE.parseMode ? loadParseQueue() : canUseInitialBootstrap() ? loadInitialBootstrap() : Promise.all([doSearch(), loadFacets()]); if (!STATE.parseMode) restorePreview(false); initialSearch.finally(() => { lastKeepaliveAt = Date.now(); window.setInterval(() => warmConnection(), KEEPALIVE_INTERVAL_MS); if (STATE.parseMode) { updateProgress(); loadFacets(); } }); } document.addEventListener("DOMContentLoaded", init);