// ---------- 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
? ` · ${skippedCount} non-JAV ▾ `
: "";
rows.push(`${escapeHtml(m.remote)} · ${escapeHtml(state)} · ${escapeHtml(m.file_count)} files${skippedNote} Refresh
`);
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))}`;
}
});