// ---------- cache status ---------- function fmtCacheAge(hours) { if (!Number.isFinite(hours)) return "?"; if (hours < 1) return `${Math.round(hours * 60)}m`; if (hours < 24) return `${hours.toFixed(1)}h`; return `${(hours / 24).toFixed(1)}d`; } let _configuredScanRoots = []; let _cacheSkippedByRemote = new Map(); let _skippedModalText = ""; function rememberConfiguredScanRoots(r) { _configuredScanRoots = [ ...(r?.configured?.default_source || []), ...(r?.configured?.default_target || []), ]; } function setupHealthRow(level, name, detail) { const icon = level === "ok" ? "✓" : level === "warn" ? "!" : level === "fail" ? "✗" : "i"; return `
${icon}${escapeHtml(name)}${escapeHtml(detail)}
`; } function openSkippedModal(remote) { const items = _cacheSkippedByRemote.get(remote) || []; const summary = document.getElementById("skipped-modal-summary"); const list = document.getElementById("skipped-modal-list"); document.getElementById("skipped-modal-subtitle").textContent = `${remote} · ${items.length} skipped`; const reasonCounts = new Map(); for (const item of items) reasonCounts.set(item.reason || "unparsed ID", (reasonCounts.get(item.reason || "unparsed ID") || 0) + 1); summary.innerHTML = [...reasonCounts.entries()] .sort((a, b) => b[1] - a[1]) .map(([reason, count]) => `${escapeHtml(count)} ${escapeHtml(reason)}`) .join(""); list.innerHTML = items.map((item) => `
${escapeHtml(item.name || item.path || "?")}
${escapeHtml(item.reason || "unparsed ID")}
${escapeHtml(item.path || "")}
`).join("") || `
No skipped IDs recorded for this remote.
`; _skippedModalText = [ `Skipped IDs for ${remote}`, ...items.map((item) => `${item.name || item.path || "?"}\t${item.reason || "unparsed ID"}\t${item.path || ""}`), ].join("\n"); openModal("skipped-modal"); } function closeSkippedModal() { closeModal("skipped-modal"); } document.getElementById("skipped-modal-close").addEventListener("click", closeSkippedModal); document.getElementById("skipped-modal-done").addEventListener("click", closeSkippedModal); document.getElementById("skipped-modal").addEventListener("click", (event) => { if (event.target.id === "skipped-modal") closeSkippedModal(); }); document.getElementById("skipped-modal-copy").addEventListener("click", async () => { if (!_skippedModalText) return; await navigator.clipboard.writeText(_skippedModalText); const btn = document.getElementById("skipped-modal-copy"); btn.textContent = "Copied"; setTimeout(() => { btn.textContent = "Copy List"; }, 1200); }); document.getElementById("setup-health-run").addEventListener("click", (event) => keepActionViewport(event.currentTarget, async () => { const out = document.getElementById("setup-health-results"); clearNativeRepairCard(); out.textContent = "checking setup health..."; const [settings, cache, host] = await Promise.all([ chrome.runtime.sendMessage({ type: "get-settings" }), chrome.runtime.sendMessage({ type: "cache-status" }), chrome.runtime.sendMessage({ type: "host-status" }), ]); const rows = []; const mode = settings?.quickMode !== false ? "LIVE" : "CACHE"; rows.push(setupHealthRow(settings?.scanPaused ? "warn" : "ok", "Search state", settings?.scanPaused ? `${mode} mode · scanning paused` : `${mode} mode · scanning enabled`)); rows.push(setupHealthRow("info", "Library profile", settings?.activeProfile || "config.json defaults")); const nativeBlocked = [cache, host].find((r) => r && !r.ok && r.error_kind); if (nativeBlocked) await renderNativeMessagingFailure(nativeBlocked); if (!cache?.ok && cache?.error_kind) { rows.push(setupHealthRow("warn", "Cache", "Blocked until native host registration is fixed.")); } else if (!cache?.ok) { rows.push(setupHealthRow("fail", "Cache", cache?.error || "cache status unavailable")); } else if (!cache.cache_exists) { rows.push(setupHealthRow("warn", "Cache", "cache.json missing; cached searches need a rebuild")); } else { const remotes = cache.remotes || []; const stale = remotes.filter((r) => r.stale || r.status === "never_scanned"); const files = remotes.reduce((sum, r) => sum + Number(r.file_count || 0), 0); rows.push(setupHealthRow(stale.length || (cache.warnings || []).length ? "warn" : "ok", "Cache", `${files.toLocaleString()} files · ${remotes.length} remote(s) · ${stale.length} stale/unscanned`)); } if (!host?.ok && host?.error_kind) { rows.push(setupHealthRow("warn", "Native host", "Registration is required before host checks can run.")); } else if (!host?.ok) { rows.push(setupHealthRow("fail", "Native host", host?.error || "host status unavailable")); } else { const failed = (host.checks || []).filter((c) => c.status === "fail"); rows.push(setupHealthRow(failed.length ? "fail" : "ok", "Native host", failed.length ? `${failed.length} registration check(s) failed; use Diagnostics` : "registration checks passed")); } out.innerHTML = rows.join(""); }) ); // Three-state UX (docs/CACHE_CONTRACT.md): fresh / stale_by_rules / schema_mismatch. // Renders an inline banner above the per-remote list. Stale_by_rules adds a // "Re-extract IDs" button that triggers the fast rebuild without rclone. function renderCacheContractBanner(r) { const state = r.cache_state; if (r.rules_info_error) { return `
⚠ rules lookup failed: ${escapeHtml(r.rules_info_error)}
`; } if (state === "fresh") { return `
✓ Cache up to date with current ID rules.
`; } if (state === "stale_by_rules") { const sigLine = r.id_rules_signature && r.id_rules_signature !== "legacy" ? `
Cache signature: ${escapeHtml(String(r.id_rules_signature).slice(0, 22))}…
` : `
Cache predates the two-tier contract (legacy header).
`; return `
! Cache is stale by rules. ID extraction rules have changed since this cache was built. Some jav_id values may be out of date. ${sigLine}
`; } if (state === "schema_mismatch") { return `
Cache schema mismatch. The on-disk cache shape is incompatible (schema ${escapeHtml(r.cache_schema ?? "?")} vs expected ${escapeHtml(r.expected_cache_schema ?? "?")}). A full re-scan is required.
`; } return ""; } document.getElementById("cache-status-run").addEventListener("click", async () => { const out = document.getElementById("cache-status-results"); out.textContent = "checking cache..."; try { const r = await chrome.runtime.sendMessage({ type: "cache-status" }); if (!r || !r.ok) { out.innerHTML = `error: ${escapeHtml(r?.error || "no response")}`; return; } rememberConfiguredScanRoots(r); _cacheSkippedByRemote = new Map((r.remotes || []).map((m) => [m.remote, m.skipped_items || []])); try { const ages = (r.remotes || []) .filter((m) => m.status !== "never_scanned" && Number.isFinite(Number(m.age_hours))) .map((m) => Number(m.age_hours)); const minAge = ages.length ? Math.min(...ages) : null; chrome.storage.local.set({ badge_cache_age_hours: minAge, badge_cache_stale_hours: Number(r.stale_hours) || 24, }); } catch {} if (!r.cache_exists) { const configured = (r.remotes || []).map((m) => `
! ${escapeHtml(m.remote)} · never scanned
` ); out.innerHTML = [ `
cache not found
`, `
${escapeHtml(r.cache_path || "")}
`, ...configured, ].join(""); return; } const rows = [ `
Path: ${escapeHtml(r.cache_path || "")}
`, `
Version: ${escapeHtml(r.version ?? "?")}
`, `
Stale after: ${escapeHtml(r.stale_hours ?? 24)}h
`, `
Configured target: ${escapeHtml((r.configured?.default_target || []).join(", ") || "(none)")}
`, `
Configured source: ${escapeHtml((r.configured?.default_source || []).join(", ") || "(none)")}
`, ]; rows.push(renderCacheContractBanner(r)); for (const m of r.remotes || []) { const color = m.status === "never_scanned" || m.stale ? "#ffa" : "#afa"; const state = m.status === "never_scanned" ? "never scanned" : `${m.status || (m.stale ? "stale" : "fresh")} · age ${fmtCacheAge(m.age_hours)}`; const skippedCount = Number(m.skipped_count) || 0; const skippedNote = skippedCount ? ` · ` : ""; rows.push(`
${escapeHtml(m.remote)} · ${escapeHtml(state)} · ${escapeHtml(m.file_count)} files${skippedNote}
`); for (const issue of m.issues || []) { rows.push(`
! ${escapeHtml(issue.count)} ${escapeHtml(issue.message)}
`); } } if ((r.warnings || []).length) { rows.push(`
Rebuild cache recommended:
`); for (const w of r.warnings || []) { rows.push(`
! ${escapeHtml(w.message || w.code)}
`); } } out.innerHTML = rows.join(""); } catch (err) { out.innerHTML = `error: ${escapeHtml(err.message || String(err))}`; } });