const HOTNESS_AUTH_TOKEN_KEY = "video-hotness-auth-token-v1"; const authGate = document.querySelector("#auth-gate"); const authForm = document.querySelector("#auth-form"); const authPassword = document.querySelector("#auth-password"); const authSubmit = document.querySelector("#auth-submit"); const authMessage = document.querySelector("#auth-message"); const form = document.querySelector("#collect-form"); const input = document.querySelector("#program-name"); const button = document.querySelector("#collect-button"); const exportLink = document.querySelector("#export-link"); const statusDot = document.querySelector("#status-dot"); const statusText = document.querySelector("#status-text"); const tableTitle = document.querySelector("#table-title"); const runCount = document.querySelector("#run-count"); const cards = document.querySelector("#cards"); const programList = document.querySelector("#program-list"); const networkLinks = document.querySelector("#network-links"); const collectPlatformBox = document.querySelector(".collect-platforms"); const mobileNote = document.querySelector("#mobile-note"); const mobileDeviceNameInput = document.querySelector("#mobile-device-name"); const saveDeviceNameButton = document.querySelector("#save-device-name-button"); const saveOfflineButton = document.querySelector("#save-offline-button"); const offlineCount = document.querySelector("#offline-count"); const offlineList = document.querySelector("#offline-list"); const clearOfflineButton = document.querySelector("#clear-offline-button"); const syncOfflineButton = document.querySelector("#sync-offline-button"); const mobileBatchText = document.querySelector("#mobile-batch-text"); const saveBatchOfflineButton = document.querySelector("#save-batch-offline-button"); const offlineStatus = document.querySelector("#offline-status"); const installHint = document.querySelector("#install-hint"); const installStatus = document.querySelector("#install-status"); const installAppButton = document.querySelector("#install-app-button"); const mobileServerUrlInput = document.querySelector("#mobile-server-url"); const saveMobileServerButton = document.querySelector("#save-mobile-server-button"); const testMobileServerButton = document.querySelector("#test-mobile-server-button"); const mobileBindingSummary = document.querySelector("#mobile-binding-summary"); const mobileAppState = document.querySelector("#mobile-app-state"); const MOBILE_DRAFTS_KEY = "video-hotness-mobile-drafts-v1"; const MOBILE_DEVICE_KEY = "video-hotness-mobile-device-v1"; const MOBILE_SERVER_KEY = "video-hotness-mobile-server-v1"; const platformOrder = ["tencent", "youku", "iqiyi", "mgtv"]; const platformLabels = { tencent: "腾讯视频", youku: "优酷", iqiyi: "爱奇艺", mgtv: "芒果TV", }; const metricLabels = { tencent: "热度值", youku: "热度值", iqiyi: "内容热度", mgtv: "播放次数", }; const urlInputs = { tencent: document.querySelector("#url-tencent"), youku: document.querySelector("#url-youku"), iqiyi: document.querySelector("#url-iqiyi"), mgtv: document.querySelector("#url-mgtv"), }; let activeName = ""; let dirtyUrlInputs = new Set(); let deferredInstallPrompt = null; let appStarted = false; let authSubmitting = false; authForm?.addEventListener("submit", async (event) => { event.preventDefault(); await submitAccessPassword(); }); authSubmit?.addEventListener("click", async (event) => { event.preventDefault(); await submitAccessPassword(); }); for (const [platform, element] of Object.entries(urlInputs)) { element.addEventListener("input", () => { dirtyUrlInputs.add(platform); }); } input.addEventListener("input", () => { const name = input.value.trim(); if (activeName && name !== activeName) { clearUrlInputs(); } }); form.addEventListener("submit", async (event) => { event.preventDefault(); const name = input.value.trim(); if (!name) return; activeName = name; const platforms = readCollectPlatforms(); if (platforms.length === 0) { setStatus("error", "请至少选择一个采集平台"); return; } setBusy(true, `正在采集《${name}》`); try { const payload = await postJson("/api/collect", { name, urls: readUrlInputs(), platforms }); renderHistory(payload.history); await refreshPrograms(); setStatus("ok", `已新增 ${formatTime(payload.collection.captured_at)} 这一列`); } catch (error) { setStatus("error", error.message); } finally { setBusy(false); } }); collectPlatformBox.addEventListener("change", (event) => { if (!event.target.matches("input[type='checkbox']")) return; updateCollectPlatformState(); }); saveOfflineButton.addEventListener("click", () => { saveOfflineDraft(); }); saveDeviceNameButton.addEventListener("click", () => { saveMobileDeviceName(); }); saveMobileServerButton.addEventListener("click", () => { saveMobileServerUrl(); }); testMobileServerButton.addEventListener("click", () => { testMobileServerConnection(); }); installAppButton.addEventListener("click", () => { installMobileApp(); }); saveBatchOfflineButton.addEventListener("click", () => { saveBatchOfflineDrafts(); }); syncOfflineButton.addEventListener("click", () => { syncOfflineDrafts(); }); clearOfflineButton.addEventListener("click", () => { const drafts = readOfflineDrafts(); if (drafts.length === 0) return; if (!window.confirm(`确定清空 ${drafts.length} 条手机待同步记录吗?`)) return; localStorage.setItem(MOBILE_DRAFTS_KEY, "[]"); renderOfflineDrafts(); setStatus("ok", "已清空手机待同步列表"); }); offlineList.addEventListener("click", (event) => { const editButton = event.target.closest("[data-edit-draft]"); if (editButton) { editOfflineDraft(editButton.dataset.editDraft); return; } const deleteButton = event.target.closest("[data-delete-draft]"); if (deleteButton) { deleteOfflineDraft(deleteButton.dataset.deleteDraft); } }); programList.addEventListener("click", async (event) => { const item = event.target.closest("[data-name]"); if (!item) return; activeName = item.dataset.name; input.value = activeName; await loadHistory(activeName); }); window.addEventListener("online", updateOfflineStatus); window.addEventListener("offline", updateOfflineStatus); window.addEventListener("beforeinstallprompt", (event) => { event.preventDefault(); deferredInstallPrompt = event; updateInstallPrompt("ready"); }); window.addEventListener("appinstalled", () => { deferredInstallPrompt = null; updateInstallPrompt("installed"); }); initializeApp(); async function initializeApp() { if (!(await ensureAccessAuth())) return; startApp(); } async function startApp() { if (appStarted) return; appStarted = true; updateCollectPlatformState(); mobileDeviceNameInput.value = mobileDeviceName(); mobileServerUrlInput.value = mobileServerBaseUrl(); registerMobileServiceWorker(); updateInstallPrompt(isStandaloneDisplay() ? "installed" : "manual"); updateOfflineStatus(); updateMobileBindingSummary(); renderOfflineDrafts(); await Promise.all([refreshPrograms(), loadNetworkLinks()]); } async function registerMobileServiceWorker() { if (!("serviceWorker" in navigator)) { if (installStatus) installStatus.textContent = "当前浏览器不支持离线缓存,可继续使用手机待同步列表。"; return; } try { await navigator.serviceWorker.register("/mobile-sw.js"); } catch { if (installStatus) installStatus.textContent = "离线缓存注册失败,可刷新后重试。"; } } async function installMobileApp() { if (!deferredInstallPrompt) { updateInstallPrompt("manual"); return; } installAppButton.disabled = true; deferredInstallPrompt.prompt(); const choice = await deferredInstallPrompt.userChoice.catch(() => ({ outcome: "dismissed" })); deferredInstallPrompt = null; installAppButton.disabled = false; updateInstallPrompt(choice.outcome === "accepted" ? "installed" : "manual"); } function updateInstallPrompt(state) { if (!installStatus || !installAppButton) return; installHint.classList.toggle("install-ready", state === "ready"); if (state === "ready") { installStatus.textContent = "当前浏览器支持直接安装,点击按钮后会添加到手机桌面。"; installAppButton.hidden = false; return; } installAppButton.hidden = true; installStatus.textContent = state === "installed" ? "已用 App 模式打开;离线录入和待同步列表可继续使用。" : "如果没有安装按钮,请在手机浏览器菜单选择“添加到主屏幕”。"; } function isStandaloneDisplay() { return window.matchMedia?.("(display-mode: standalone)").matches || window.navigator.standalone === true; } function updateOfflineStatus() { if (!offlineStatus) return; const online = navigator.onLine; const pendingDrafts = readOfflineDrafts().filter((draft) => draft.sync_status !== "synced"); if (mobileAppState) { mobileAppState.textContent = online ? "在线" : "离线"; mobileAppState.classList.toggle("offline", !online); } offlineStatus.classList.toggle("offline", !online); offlineStatus.innerHTML = online && pendingDrafts.length ? `有 ${pendingDrafts.length} 条可同步电脑可访问时点击“同步到电脑”,同步后会显示电脑已收到。` : online ? `离线录入已准备首次打开后会缓存手机版;离开局域网时仍可保存待同步。` : `当前离线可以继续录入并保存待同步;回到局域网后再同步到电脑。`; } async function loadHistory(name) { setStatus("busy", `正在读取《${name}》历史`); try { const payload = await getJson(`/api/history?name=${encodeURIComponent(name)}`); renderHistory(payload.history); setStatus("ok", `已载入《${name}》`); } catch (error) { setStatus("error", error.message); } } async function refreshPrograms() { try { const payload = await getJson("/api/programs"); renderPrograms(payload.programs || []); } catch { renderPrograms([]); } } async function loadNetworkLinks() { try { const payload = await getJson("/api/network"); const urls = payload.urls || []; networkLinks.innerHTML = urls.length ? urls.map((url) => `${escapeHtml(url)}`).join("
") : "没有读取到局域网地址,可先用本机浏览器访问。"; } catch { networkLinks.textContent = "局域网地址读取失败。"; } } function mobileServerBaseUrl() { const saved = (localStorage.getItem(MOBILE_SERVER_KEY) || "").trim(); return saved || window.location.origin; } function normalizeServerUrl(value) { const text = String(value || "").trim().replace(/\/+$/, ""); if (!text) return ""; try { const parsed = new URL(text); return parsed.origin; } catch { return ""; } } function apiUrl(path) { return `${mobileServerBaseUrl()}${path}`; } function saveMobileServerUrl() { const normalized = normalizeServerUrl(mobileServerUrlInput.value); if (!normalized) { setStatus("error", "请输入正确的电脑或 NAS 地址,例如 http://192.168.18.120:3001"); return; } localStorage.setItem(MOBILE_SERVER_KEY, normalized); mobileServerUrlInput.value = normalized; updateMobileBindingSummary(); setStatus("ok", `已绑定地址:${normalized}`); } async function testMobileServerConnection() { const normalized = normalizeServerUrl(mobileServerUrlInput.value) || mobileServerBaseUrl(); if (normalized !== mobileServerBaseUrl()) { localStorage.setItem(MOBILE_SERVER_KEY, normalized); mobileServerUrlInput.value = normalized; } testMobileServerButton.disabled = true; setStatus("busy", "正在测试电脑端连接"); try { const payload = await getJson("/api/network"); updateMobileBindingSummary({ ok: true }); setStatus("ok", `连接正常,读取到 ${(payload.urls || []).length} 个手机访问地址`); } catch (error) { updateMobileBindingSummary({ ok: false, error: error.message }); setStatus("error", `连接失败:${error.message}`); } finally { testMobileServerButton.disabled = false; } } function updateMobileBindingSummary(result = null) { const base = mobileServerBaseUrl(); const pendingDrafts = readOfflineDrafts().filter((draft) => draft.sync_status !== "synced").length; const resultText = result?.ok ? " · 连接正常" : result?.error ? ` · 连接失败:${result.error}` : ""; mobileBindingSummary.textContent = `当前绑定:${base} · 待同步 ${pendingDrafts} 条${resultText}`; } function renderPrograms(programs) { if (programs.length === 0) { programList.innerHTML = `
暂无历史
`; return; } programList.innerHTML = programs.map((program) => ` `).join(""); } function saveOfflineDraft() { const name = input.value.trim(); if (!name) { setStatus("error", "请先输入节目名"); return; } const draft = createOfflineDraft({ name, note: mobileNote.value.trim(), urls: readAllUrlInputs(), platforms: readCollectPlatforms(), }); const drafts = readOfflineDrafts(); drafts.unshift(draft); localStorage.setItem(MOBILE_DRAFTS_KEY, JSON.stringify(drafts.slice(0, 200))); renderOfflineDrafts(); updateOfflineStatus(); updateMobileBindingSummary(); setStatus("ok", `已保存《${name}》到手机待同步`); } function saveBatchOfflineDrafts() { const names = parseMobileBatchNames(mobileBatchText.value); if (names.length === 0) { setStatus("error", "请先粘贴节目名单"); return; } const drafts = readOfflineDrafts(); const newDrafts = names.map((name) => createOfflineDraft({ name, note: "批量离线录入", urls: {}, platforms: readCollectPlatforms(), })); localStorage.setItem(MOBILE_DRAFTS_KEY, JSON.stringify([...newDrafts, ...drafts].slice(0, 200))); mobileBatchText.value = ""; renderOfflineDrafts(); updateOfflineStatus(); updateMobileBindingSummary(); setStatus("ok", `已批量保存 ${newDrafts.length} 条待同步`); } function parseMobileBatchNames(text) { const seen = new Set(); const names = []; for (const line of String(text || "").split(/\r?\n/)) { const name = line.split(/[,,\t]/)[0].trim(); if (!name || seen.has(name) || /节目|名称|片名/.test(name)) continue; seen.add(name); names.push(name); } return names; } function createOfflineDraft({ name, note = "", urls = {}, platforms = [] }) { return { id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, name, note, urls, platforms, device_name: mobileDeviceName(), created_at: new Date().toISOString(), sync_status: "pending", }; } function readOfflineDrafts() { try { const value = JSON.parse(localStorage.getItem(MOBILE_DRAFTS_KEY) || "[]"); return Array.isArray(value) ? value.filter((draft) => draft?.name) : []; } catch { return []; } } function renderOfflineDrafts() { const drafts = readOfflineDrafts(); const pendingDrafts = drafts.filter((draft) => draft.sync_status !== "synced"); offlineCount.textContent = String(drafts.length); if (drafts.length === 0) { offlineList.innerHTML = `
暂无手机待同步记录
`; clearOfflineButton.disabled = true; syncOfflineButton.disabled = true; return; } clearOfflineButton.disabled = false; syncOfflineButton.disabled = pendingDrafts.length === 0; offlineList.innerHTML = drafts.slice(0, 20).map((draft) => { const urlCount = Object.values(draft.urls || {}).filter(Boolean).length; const note = draft.note ? `
${escapeHtml(draft.note)}
` : ""; const isSynced = draft.sync_status === "synced"; const syncLabel = isSynced ? "已同步" : "待同步"; return `
${escapeHtml(draft.name)} ${syncLabel}
${formatTime(draft.created_at)} · ${urlCount} 个链接 · ${escapeHtml((draft.platforms || []).map((platform) => platformLabels[platform] || platform).join("、") || "未选平台")}
${note}
`; }).join(""); } function editOfflineDraft(id) { const drafts = readOfflineDrafts(); const draft = drafts.find((item) => item.id === id); if (!draft) return; const nextName = window.prompt("修改节目名", draft.name); if (nextName === null) return; const cleanName = nextName.trim(); if (!cleanName) { setStatus("error", "节目名不能为空"); return; } const nextNote = window.prompt("修改备注", draft.note || ""); if (nextNote === null) return; const updated = drafts.map((item) => item.id === id ? { ...item, name: cleanName, note: nextNote.trim(), sync_status: "pending", edited_at: new Date().toISOString() } : item); localStorage.setItem(MOBILE_DRAFTS_KEY, JSON.stringify(updated)); renderOfflineDrafts(); updateOfflineStatus(); updateMobileBindingSummary(); setStatus("ok", `已更新《${cleanName}》`); } function deleteOfflineDraft(id) { const drafts = readOfflineDrafts(); const draft = drafts.find((item) => item.id === id); if (!draft) return; if (!window.confirm(`删除《${draft.name}》这条手机记录吗?`)) return; localStorage.setItem(MOBILE_DRAFTS_KEY, JSON.stringify(drafts.filter((item) => item.id !== id))); renderOfflineDrafts(); updateOfflineStatus(); updateMobileBindingSummary(); setStatus("ok", `已删除《${draft.name}》`); } async function syncOfflineDrafts() { const drafts = readOfflineDrafts(); const pendingDrafts = drafts.filter((draft) => draft.sync_status !== "synced"); if (pendingDrafts.length === 0) { setStatus("ok", "没有待同步的手机记录"); renderOfflineDrafts(); return; } syncOfflineButton.disabled = true; setStatus("busy", `正在同步 ${pendingDrafts.length} 条到电脑`); try { const payload = await postJson("/api/mobile-sync", { deviceName: mobileDeviceName(), drafts: pendingDrafts, }); const acceptedIds = new Set((payload.accepted || []).map((item) => item.id)); const syncedAt = new Date().toISOString(); const updatedDrafts = drafts.map((draft) => acceptedIds.has(draft.id) ? { ...draft, sync_status: "synced", synced_at: syncedAt } : draft); localStorage.setItem(MOBILE_DRAFTS_KEY, JSON.stringify(updatedDrafts)); renderOfflineDrafts(); updateOfflineStatus(); updateMobileBindingSummary(); setStatus("ok", `电脑已收到 ${acceptedIds.size} 条,已进入待处理`); } catch (error) { setStatus("error", `同步失败:${error.message}`); } finally { syncOfflineButton.disabled = false; renderOfflineDrafts(); updateMobileBindingSummary(); } } function mobileDeviceName() { let deviceName = (mobileDeviceNameInput?.value || localStorage.getItem(MOBILE_DEVICE_KEY) || "").trim(); if (!deviceName) { deviceName = `mobile-${Math.random().toString(16).slice(2, 8)}`; localStorage.setItem(MOBILE_DEVICE_KEY, deviceName); } return deviceName; } function saveMobileDeviceName() { const deviceName = mobileDeviceNameInput.value.trim(); if (!deviceName) { setStatus("error", "请输入这台手机或录入人的名称"); return; } localStorage.setItem(MOBILE_DEVICE_KEY, deviceName); renderOfflineDrafts(); setStatus("ok", `已保存手机名称:${deviceName}`); } function renderHistory(history) { const runs = history.runs || []; tableTitle.textContent = history.name ? `《${history.name}》` : "还没有采集结果"; runCount.textContent = `${runs.length} 次`; exportLink.href = history.name ? `/api/export?name=${encodeURIComponent(history.name)}` : "#"; exportLink.setAttribute("aria-disabled", history.name && runs.length > 0 ? "false" : "true"); syncUrlInputs(history); if (runs.length === 0) { cards.innerHTML = `
暂无采集结果
`; return; } cards.innerHTML = platformOrder.map((platform) => { const row = history.platforms?.[platform] || { values: {} }; const latestRun = runs[runs.length - 1]; const latest = row.values?.[latestRun]; return renderPlatformCard(platform, row, latest, runs); }).join(""); } function renderPlatformCard(platform, row, latest, runs) { const label = row.platform_label || platformLabels[platform] || platform; const metric = row.metric_label || metricLabels[platform] || "指标值"; const url = row.url || latest?.url || ""; const latestHtml = renderLatest(latest); const historyRows = runs.slice(-5).reverse().map((run) => { const value = row.values?.[run]; const shown = value?.status === "ok" ? (value.raw || value.number || "") : statusLabel(value?.status); return `
${formatTime(run)} ${escapeHtml(shown || "未采集")}
`; }).join(""); return `
${escapeHtml(label)}
${escapeHtml(metric)}
${row.metric_description ? `
${escapeHtml(row.metric_description)}
` : ""}
${url ? `打开` : ""}
${latestHtml}
${historyRows}
`; } function renderLatest(value) { if (!value) return `
未采集
`; if (value.status === "ok") { const shown = value.raw || value.number || ""; const meta = value.number && String(value.number) !== String(value.raw) ? `标准化:${value.number}` : ""; const anomaly = value.anomaly ? `异常` : ""; const credibility = renderCredibilityBadge(value.credibility); return `
${escapeHtml(shown)}${anomaly}
${credibility} ${meta ? `
${escapeHtml(meta)}
` : ""} ${value.credibility?.reason ? `
${escapeHtml(value.credibility.reason)}
` : ""} ${value.anomaly ? `
${escapeHtml(value.anomaly.message || "")}
` : ""} `; } const tone = value.status === "blocked" ? "warn" : "bad"; return `
${escapeHtml(statusLabel(value.status))}
${renderCredibilityBadge(value.credibility)}
${escapeHtml(value.error || "")}
`; } function renderCredibilityBadge(credibility) { if (!credibility?.label) return ""; return `${escapeHtml(credibility.label)}`; } function syncUrlInputs(history) { for (const platform of platformOrder) { const input = urlInputs[platform]; if (!input) continue; input.value = history.platforms?.[platform]?.url || ""; } dirtyUrlInputs.clear(); } function readUrlInputs() { return Object.fromEntries(platformOrder .filter((platform) => dirtyUrlInputs.has(platform)) .map((platform) => [ platform, urlInputs[platform]?.value.trim() || "", ])); } function readAllUrlInputs() { return Object.fromEntries(platformOrder.map((platform) => [ platform, urlInputs[platform]?.value.trim() || "", ])); } function readCollectPlatforms() { return [...collectPlatformBox.querySelectorAll("input[type='checkbox']:checked")] .map((checkbox) => checkbox.value) .filter((platform) => platformOrder.includes(platform)); } function updateCollectPlatformState() { for (const label of collectPlatformBox.querySelectorAll("label")) { const checkbox = label.querySelector("input"); label.classList.toggle("active", checkbox.checked); } } function clearUrlInputs() { for (const input of Object.values(urlInputs)) { input.value = ""; } dirtyUrlInputs.clear(); } async function getJson(url) { const response = await fetch(apiUrl(url), { headers: authHeaders() }); const payload = await response.json(); if (handleAuthFailure(response, payload)) throw new Error(payload.error || "需要输入访问密码"); if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); return payload; } async function postJson(url, body) { const response = await fetch(apiUrl(url), { method: "POST", headers: { "content-type": "application/json", ...authHeaders() }, body: JSON.stringify(body), }); const payload = await response.json(); if (handleAuthFailure(response, payload)) throw new Error(payload.error || "需要输入访问密码"); if (!response.ok) throw new Error(payload.error || `HTTP ${response.status}`); return payload; } async function ensureAccessAuth() { try { const response = await fetch(apiUrl("/api/auth/status"), { headers: authHeaders() }); const payload = await response.json(); if (!payload.enabled || payload.authorized) { hideAuthGate(); return true; } } catch { return true; } showAuthGate(""); return false; } async function submitAccessPassword() { if (authSubmitting) return; const password = authPassword?.value || ""; if (!password.trim()) { showAuthGate("请输入访问密码"); return; } authSubmitting = true; if (authSubmit) authSubmit.disabled = true; setAuthMessage("正在验证..."); try { const response = await fetch(apiUrl("/api/auth/login"), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ password }), }); const payload = await response.json(); if (!response.ok) throw new Error(payload.error || "访问密码不正确"); if (payload.token) localStorage.setItem(HOTNESS_AUTH_TOKEN_KEY, payload.token); if (authPassword) authPassword.value = ""; setAuthMessage("登录成功,正在进入..."); hideAuthGate(); await startApp(); } catch (error) { showAuthGate(error.message || "访问密码不正确"); } finally { authSubmitting = false; if (authSubmit) authSubmit.disabled = false; } } function authHeaders() { const token = localStorage.getItem(HOTNESS_AUTH_TOKEN_KEY) || ""; return token ? { "x-hotness-auth-token": token } : {}; } function handleAuthFailure(response, payload) { if (response.status !== 401 || !payload?.requires_auth) return false; localStorage.removeItem(HOTNESS_AUTH_TOKEN_KEY); showAuthGate(payload.error || "需要输入访问密码"); return true; } function showAuthGate(message = "") { if (!authGate) return; authGate.hidden = false; setAuthMessage(message); requestAnimationFrame(() => authPassword?.focus()); } function hideAuthGate() { if (authGate) authGate.hidden = true; setAuthMessage(""); } function setAuthMessage(message) { if (authMessage) authMessage.textContent = message || ""; } function setBusy(isBusy, text = "") { button.disabled = isBusy; if (isBusy) setStatus("busy", text); } function setStatus(type, text) { statusDot.className = `dot ${type}`; statusText.textContent = text; } function statusLabel(status) { return { no_match: "未找到", blocked: "被拦截", error: "错误", }[status] || status || ""; } function formatTime(value) { if (!value) return ""; const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }).format(date); } function escapeHtml(value) { return String(value ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function escapeAttribute(value) { return escapeHtml(value).replace(/`/g, "`"); }