const KEYS = [ "autoEveryPage", "autoKnownSites", "autoPageLoad", "autoSpaNavigation", "toolbarClick", "contextMenu", "keyboardShortcut", ]; // MUST match DEFAULT_SETTINGS in background.js. const DEFAULT_TRIGGERS = { autoEveryPage: false, autoKnownSites: false, autoPageLoad: true, autoSpaNavigation: true, toolbarClick: true, contextMenu: true, keyboardShortcut: true, }; // ---------- sidebar nav ---------- function activatePane(pane) { if (pane === "backup") pane = "paths"; if (pane === "review") pane = "maintenance"; const item = document.querySelector(`.side .item[data-pane="${pane}"]`) || document.querySelector('.side .item[data-pane="triggers"]'); if (!item) return; document.querySelectorAll(".side .item").forEach((i) => i.classList.remove("active")); item.classList.add("active"); document.querySelectorAll(".pane").forEach((p) => p.classList.remove("active")); const paneEl = document.getElementById("pane-" + item.dataset.pane); if (paneEl) paneEl.classList.add("active"); } for (const item of document.querySelectorAll(".side .item")) { item.addEventListener("click", async () => { activatePane(item.dataset.pane); await chrome.storage.local.set({ optionsActivePane: item.dataset.pane }); }); } function getActionScrollContainer(element) { for (let node = element?.parentElement; node; node = node.parentElement) { const overflowY = getComputedStyle(node).overflowY; if ((overflowY === "auto" || overflowY === "scroll") && node.scrollHeight > node.clientHeight) { return node; } } return document.scrollingElement || document.documentElement; } function nextAnimationFrame() { return new Promise((resolve) => requestAnimationFrame(resolve)); } function openModal(id) { const modal = document.getElementById(id); if (!modal) return; modal.classList.add("open"); modal.setAttribute("aria-hidden", "false"); } function closeModal(id) { const modal = document.getElementById(id); if (!modal) return; modal.classList.remove("open"); modal.setAttribute("aria-hidden", "true"); } async function keepActionViewport(action, run) { const beforeTop = action?.getBoundingClientRect().top; const scroller = getActionScrollContainer(action); try { return await run(); } finally { await nextAnimationFrame(); await nextAnimationFrame(); if (!action?.isConnected || !Number.isFinite(beforeTop)) return; const delta = action.getBoundingClientRect().top - beforeTop; if (Math.abs(delta) < 1) return; if (scroller === document.scrollingElement || scroller === document.documentElement) { window.scrollBy(0, delta); } else { scroller.scrollTop += delta; } } } // ---------- overlay tabs ---------- for (const tab of document.querySelectorAll(".otab")) { tab.addEventListener("click", () => { document.querySelectorAll(".otab").forEach((t) => t.classList.remove("active")); document.querySelectorAll(".otab-panel").forEach((p) => p.classList.remove("active")); tab.classList.add("active"); document.getElementById("otab-" + tab.dataset.otab).classList.add("active"); }); } // ---------- settings load / save ---------- async function load() { const { settings = {} } = await chrome.storage.sync.get("settings"); const t = Object.assign({}, DEFAULT_TRIGGERS, settings.triggers || {}); for (const k of KEYS) document.getElementById(k).checked = !!t[k]; document.getElementById("quickMode").checked = settings.quickMode !== false; document.getElementById("cacheStaleHours").value = Math.max(1, Number(settings.cacheStaleHours) || 24); document.getElementById("showOverlay").checked = settings.showOverlay !== false; // Overlay options const pos = settings.overlayPosition || "top-right"; for (const r of document.querySelectorAll('input[name="overlayPosition"]')) { r.checked = r.value === pos; } syncOverlayPosChips(); document.getElementById("overlayDuration").value = settings.overlayDuration || 5; document.getElementById("overlayGlow").checked = !!settings.overlayGlow; document.getElementById("overlayGlowColor").value = settings.overlayGlowColor || "#6ec1ff"; document.getElementById("overlayGlowBlur").value = settings.overlayGlowBlur ?? 10; document.getElementById("overlayGlowSpread").value = settings.overlayGlowSpread ?? 0; document.getElementById("overlayGlowOpacity").value = settings.overlayGlowOpacity ?? 0.35; // no-match overlay document.getElementById("noMatchOverlay").checked = !!settings.noMatchOverlay; const nmPos = settings.noMatchPosition || "top-right"; for (const r of document.querySelectorAll('input[name="noMatchPosition"]')) { r.checked = r.value === nmPos; } syncNoMatchPosChips(); document.getElementById("noMatchDuration").value = settings.noMatchDuration || 5; document.getElementById("noMatchGlow").checked = !!settings.noMatchGlow; document.getElementById("noMatchGlowColor").value = settings.noMatchGlowColor || "#ff6666"; document.getElementById("noMatchGlowBlur").value = settings.noMatchGlowBlur ?? 10; document.getElementById("noMatchGlowSpread").value = settings.noMatchGlowSpread ?? 0; document.getElementById("noMatchGlowOpacity").value = settings.noMatchGlowOpacity ?? 0.35; if (typeof updateOverlayPreview === "function") updateOverlayPreview(); if (typeof updateNoMatchPreview === "function") updateNoMatchPreview(); if (typeof updateMutualExclusion === "function") updateMutualExclusion(); document.getElementById("knownSitePatterns").value = (settings.knownSitePatterns || []).join("\n"); renderAdapters(settings.siteAdapters || []); renderNormalizers(settings.idNormalizers || []); renderPartDetectors(settings.partPatterns || []); document.getElementById("enableDelete").checked = !!settings.enableDelete; const mode = settings.deleteMode === "permanent" ? "permanent" : "trash"; document.getElementById("deleteModeTrash").checked = mode === "trash"; document.getElementById("deleteModePerm").checked = mode === "permanent"; syncRadioChips(); document.getElementById("trashDir").value = settings.trashDir || "cq:personal-files/.rclone-jav-trash"; document.getElementById("rcjavPath").value = settings.rcjavPath || ""; renderProfiles(settings.profiles || []); updateSectionSummaries(); syncDeletionControls(); } async function save(e) { const { settings: existingSettings = {} } = await chrome.storage.sync.get("settings"); const triggers = {}; for (const k of KEYS) triggers[k] = document.getElementById(k).checked; const knownSitePatterns = document.getElementById("knownSitePatterns").value .split(/[\n,]/).map((s) => s.trim()).filter(Boolean); const quickMode = document.getElementById("quickMode").checked; const cacheStaleHours = Math.max(1, Math.min(8760, parseInt(document.getElementById("cacheStaleHours").value, 10) || 24)); const showOverlay = document.getElementById("showOverlay").checked; const overlayPosition = (document.querySelector('input[name="overlayPosition"]:checked') || {}).value || "top-right"; const overlayDuration = Math.max(1, parseInt(document.getElementById("overlayDuration").value, 10) || 5); const overlayGlow = document.getElementById("overlayGlow").checked; const overlayGlowColor = document.getElementById("overlayGlowColor").value || "#6ec1ff"; const overlayGlowBlur = parseInt(document.getElementById("overlayGlowBlur").value, 10); const overlayGlowSpread = parseInt(document.getElementById("overlayGlowSpread").value, 10); const overlayGlowOpacity = parseFloat(document.getElementById("overlayGlowOpacity").value); const noMatchOverlay = document.getElementById("noMatchOverlay").checked; const noMatchPosition = (document.querySelector('input[name="noMatchPosition"]:checked') || {}).value || "top-right"; const noMatchDuration = Math.max(1, parseInt(document.getElementById("noMatchDuration").value, 10) || 5); const noMatchGlow = document.getElementById("noMatchGlow").checked; const noMatchGlowColor = document.getElementById("noMatchGlowColor").value || "#ff6666"; const noMatchGlowBlur = parseInt(document.getElementById("noMatchGlowBlur").value, 10); const noMatchGlowSpread = parseInt(document.getElementById("noMatchGlowSpread").value, 10); const noMatchGlowOpacity = parseFloat(document.getElementById("noMatchGlowOpacity").value); const siteAdapters = readAdapters(); const idNormalizers = readNormalizers(); const partPatterns = readPartDetectors(); const enableDelete = document.getElementById("enableDelete").checked; const deleteMode = document.getElementById("deleteModePerm").checked ? "permanent" : "trash"; const trashDir = document.getElementById("trashDir").value.trim() || "cq:personal-files/.rclone-jav-trash"; const rcjavPath = document.getElementById("rcjavPath").value.trim(); const payload = { triggers, knownSitePatterns, quickMode, cacheStaleHours, showOverlay, overlayPosition, overlayDuration, overlayGlow, overlayGlowColor, overlayGlowBlur, overlayGlowSpread, overlayGlowOpacity, noMatchOverlay, noMatchPosition, noMatchDuration, noMatchGlow, noMatchGlowColor, noMatchGlowBlur, noMatchGlowSpread, noMatchGlowOpacity, siteAdapters, idNormalizers, partPatterns, enableDelete, deleteMode, trashDir, rcjavPath, profiles: readProfiles(), activeProfile: existingSettings.activeProfile || "", scanPaused: !!existingSettings.scanPaused, }; const btn = e && e.target ? e.target : null; const saved = btn && btn.nextElementSibling; try { await chrome.storage.sync.set({ settings: payload }); } catch (err) { // chrome.storage.sync has 8 KB/item + 100 KB total quota. Long adapter or // normalizer lists can blow it; without this try/catch the rejection is // silently swallowed and the "Saved." chip never appears. if (saved && saved.classList.contains("saved")) { saved.textContent = "Save failed: " + (err.message || String(err)); saved.classList.add("show", "error"); setTimeout(() => { saved.classList.remove("show", "error"); saved.textContent = "Saved."; }, 4000); } else { alert("Failed to save settings: " + (err.message || String(err))); } return; } chrome.runtime.sendMessage({ type: "settings-changed" }); updateSectionSummaries(); // Show "Saved." chip on the button that triggered this if (saved && saved.classList.contains("saved")) { saved.classList.add("show"); setTimeout(() => saved.classList.remove("show"), 1500); } } for (const btn of document.querySelectorAll(".save-btn")) { btn.addEventListener("click", save); } function plural(n, word) { return `${n} ${word}${n === 1 ? "" : "s"}`; } function setNote(id, html, kind = "") { const el = document.getElementById(id); if (!el) return; el.className = "section-note" + (kind ? ` ${kind}` : ""); el.innerHTML = html; } function updateSectionSummaries() { const knownSites = document.getElementById("knownSitePatterns").value .split(/[\n,]/).map((s) => s.trim()).filter(Boolean); const triggersOn = KEYS.filter((k) => document.getElementById(k)?.checked); const autoOn = document.getElementById("autoEveryPage").checked || document.getElementById("autoKnownSites").checked; setNote("trigger-summary", `${autoOn ? "Auto-check is enabled" : "Auto-check is off"} · ${plural(knownSites.length, "known site")} · ${plural(triggersOn.length, "manual/navigation control")} enabled`, autoOn ? "" : "warn"); const match = document.getElementById("showOverlay").checked; const noMatch = document.getElementById("noMatchOverlay").checked; setNote("overlay-summary", `Match overlay ${match ? "on" : "off"} · no-match overlay ${noMatch ? "on" : "off"} · positions are kept separate to avoid overlap`, match || noMatch ? "" : "warn"); const adapters = readAdapters(); setNote("adapter-summary", `${plural(adapters.length, "custom adapter")} configured · built-in ClearJAV preset always runs`, adapters.length ? "" : "info"); const normalizers = readNormalizers(); const partPatterns = readPartDetectors(); setNote("normalizer-summary", `${plural(normalizers.length, "custom normalizer")} · ${plural(partPatterns.length, "custom part detector")} · built-in FC2 and multipart rules active`, normalizers.length || partPatterns.length ? "" : "info"); const quick = document.getElementById("quickMode").checked; setNote("search-summary", quick ? "LIVE mode: single-ID lookups query rclone directly and bypass cache." : "CACHE mode: lookups use cache.json; rebuild cache after library or ID-normalization changes."); const path = document.getElementById("rcjavPath").value.trim(); setNote("paths-summary", path ? `Using extension override: ${escapeHtml(path)}` : "Using native host default rc-jav path.", path ? "" : "info"); const profiles = readProfiles(); setNote("profiles-summary", profiles.length ? `${plural(profiles.length, "profile")} configured. Empty source/target lists inherit config.json defaults.` : "No profiles configured. Popup searches use rc-jav config.json defaults.", profiles.length ? "" : "info"); const settingCount = Object.keys(SETTINGS_SCHEMA).length; setNote("backup-summary", `Backups include ${settingCount} recognized settings keys. Export before moving the extension or changing its ID.`, "info"); const deleteOn = document.getElementById("enableDelete").checked; const mode = document.getElementById("deleteModePerm").checked ? "permanent delete" : "trash mode"; setNote("deletion-summary", deleteOn ? `Deletion is enabled in ${mode}. Popup confirmation still requires typing the exact filename.` : "Deletion is disabled. Popup delete controls stay hidden.", deleteOn ? "danger" : ""); } function syncDeletionControls() { const enabled = document.getElementById("enableDelete").checked; for (const id of ["deleteModeTrash", "deleteModePerm", "trashDir"]) { const el = document.getElementById(id); if (el) el.disabled = !enabled; } for (const el of [document.getElementById("deleteModeTrashLbl")?.closest(".fieldset"), document.getElementById("trashDir")?.closest(".fieldset")]) { if (el) el.classList.toggle("disabled-soft", !enabled); } } document.getElementById("enableDelete").addEventListener("change", (event) => { if (!event.target.checked) return; event.target.checked = false; syncDeletionControls(); updateSectionSummaries(); openModal("delete-enable-modal"); }); function closeDeleteEnableModal() { closeModal("delete-enable-modal"); } document.getElementById("delete-enable-modal-confirm").addEventListener("click", () => { document.getElementById("enableDelete").checked = true; closeDeleteEnableModal(); syncDeletionControls(); updateSectionSummaries(); }); for (const id of ["delete-enable-modal-close", "delete-enable-modal-cancel"]) { document.getElementById(id).addEventListener("click", closeDeleteEnableModal); } document.getElementById("delete-enable-modal").addEventListener("click", (event) => { if (event.target.id === "delete-enable-modal") closeDeleteEnableModal(); }); document.addEventListener("input", (e) => { if (e.target && e.target.closest(".pane")) updateSectionSummaries(); }); document.addEventListener("change", (e) => { if (e.target && e.target.closest(".pane")) { if (e.target.id === "enableDelete") syncDeletionControls(); updateSectionSummaries(); } }); // ---------- backup & restore ---------- document.getElementById("export-settings").addEventListener("click", async () => { const { settings = {} } = await chrome.storage.sync.get("settings"); const payload = { _meta: { app: "rclonex", exported_at: new Date().toISOString(), version: 1 }, settings, }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); a.href = url; a.download = `rclonex-settings-${stamp}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); setBackupStatus("exported.", "ok"); }); document.getElementById("import-settings").addEventListener("click", () => { document.getElementById("import-file").click(); }); // Allowlist of settings keys with their expected primitive types. Imports // containing any other key are dropped silently; primitives must match. // Nested objects (triggers, siteAdapters[].*) get a recursive shallow check. const SETTINGS_SCHEMA = { triggers: "object", knownSitePatterns: "array", quickMode: "boolean", cacheStaleHours: "number", scanPaused: "boolean", rcjavPath: "string", showOverlay: "boolean", overlayPosition: "string", overlayDuration: "number", overlayGlow: "boolean", overlayGlowColor: "string", overlayGlowBlur: "number", overlayGlowSpread: "number", overlayGlowOpacity: "number", noMatchOverlay: "boolean", noMatchPosition: "string", noMatchDuration: "number", noMatchGlow: "boolean", noMatchGlowColor: "string", noMatchGlowBlur: "number", noMatchGlowSpread: "number", noMatchGlowOpacity: "number", enableDelete: "boolean", deleteMode: "string", trashDir: "string", idNormalizers: "array", partPatterns: "array", siteAdapters: "array", profiles: "array", activeProfile: "string", }; function _typeOf(v) { if (Array.isArray(v)) return "array"; if (v === null) return "null"; return typeof v; } function sanitizeImportedSettings(incoming) { if (typeof incoming !== "object" || incoming === null || Array.isArray(incoming)) { throw new Error("settings must be a JSON object"); } const out = {}; const dropped = []; for (const [k, v] of Object.entries(incoming)) { const expected = SETTINGS_SCHEMA[k]; if (!expected) { dropped.push(k); continue; } if (_typeOf(v) !== expected) { dropped.push(`${k}(wrong type)`); continue; } out[k] = v; } return { sanitized: out, dropped }; } let pendingImport = null; function closeImportModal() { pendingImport = null; closeModal("import-modal"); } function openImportModal(fileName, sanitized, dropped) { pendingImport = { sanitized, dropped }; document.getElementById("import-modal-subtitle").textContent = fileName || "settings JSON"; document.getElementById("import-modal-body").innerHTML = `
!OverwriteCurrent settings will be replaced by ${escapeHtml(Object.keys(sanitized).length)} imported value(s).
iProfiles${escapeHtml(Array.isArray(sanitized.profiles) ? `${sanitized.profiles.length} profile(s) in this import` : "No profile list in this import")}
${sanitized.enableDelete ? "!" : "i"}Deletion${escapeHtml(sanitized.enableDelete ? `Enabled in imported settings (${sanitized.deleteMode || "trash"} mode)` : "Not enabled by this import")}
${dropped.length ? `
!Ignored keys${escapeHtml(`${dropped.length}: ${dropped.slice(0, 8).join(", ")}${dropped.length > 8 ? "..." : ""}`)}
` : `
SchemaAll imported keys are recognized.
`} `; openModal("import-modal"); } document.getElementById("import-file").addEventListener("change", async (e) => { const file = e.target.files?.[0]; if (!file) return; try { const text = await file.text(); const data = JSON.parse(text); const incoming = data.settings || data; const { sanitized, dropped } = sanitizeImportedSettings(incoming); if (Object.keys(sanitized).length === 0) { throw new Error("no recognized settings keys in file"); } openImportModal(file.name, sanitized, dropped); } catch (err) { setBackupStatus("import failed: " + err.message, "fail"); } e.target.value = ""; }); document.getElementById("import-modal-confirm").addEventListener("click", async () => { if (!pendingImport) return; const { sanitized, dropped } = pendingImport; try { await chrome.storage.sync.set({ settings: sanitized }); chrome.runtime.sendMessage({ type: "settings-changed" }); closeImportModal(); setBackupStatus(`imported${dropped.length ? ` (${dropped.length} key(s) dropped)` : ""}. reloading...`, "ok"); setTimeout(() => location.reload(), 600); } catch (err) { setBackupStatus("import failed: " + (err.message || String(err)), "fail"); } }); document.getElementById("import-modal-close").addEventListener("click", closeImportModal); document.getElementById("import-modal-cancel").addEventListener("click", () => { setBackupStatus("cancelled.", "warn"); closeImportModal(); }); document.getElementById("import-modal").addEventListener("click", (event) => { if (event.target.id === "import-modal") closeImportModal(); }); function setBackupStatus(msg, kind) { const el = document.getElementById("backup-status"); el.textContent = msg; el.style.color = kind === "ok" ? "#afa" : kind === "fail" ? "#faa" : "#ffa"; setTimeout(() => { el.textContent = ""; }, 4000); } function appendListValue(textareaId, value) { const el = document.getElementById(textareaId); const parts = el.value.split(/[\n,]/).map((s) => s.trim()).filter(Boolean); if (!parts.some((p) => p.toLowerCase() === value.toLowerCase())) parts.push(value); el.value = parts.join("\n"); updateSectionSummaries(); } document.getElementById("add-clearjav-site").addEventListener("click", () => { appendListValue("knownSitePatterns", "clearjav.com"); document.getElementById("known-site-status").textContent = "added clearjav.com"; }); document.getElementById("add-current-site").addEventListener("click", async () => { const status = document.getElementById("known-site-status"); status.textContent = "finding current site..."; const all = await chrome.tabs.query({}); const web = all.filter((t) => t.url && /^https?:/.test(t.url)); web.sort((a, b) => (b.lastAccessed || 0) - (a.lastAccessed || 0)); const tab = web[0]; if (!tab) { status.textContent = "no http/https tab found"; return; } try { const host = new URL(tab.url).hostname.replace(/^www\./, ""); appendListValue("knownSitePatterns", host); status.textContent = `added ${host}`; } catch { status.textContent = "could not read active site"; } }); // ---------- 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(""); }) ); 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 || []])); 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)")}
`, ]; 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))}`; } }); // ---------- recent activity ---------- function fmtActivityWhen(ts) { if (!ts) return "?"; const s = Math.max(0, Math.round((Date.now() - ts) / 1000)); if (s < 60) return `${s}s ago`; if (s < 3600) return `${Math.round(s / 60)}m ago`; if (s < 86400) return `${Math.round(s / 3600)}h ago`; return `${Math.round(s / 86400)}d ago`; } let recentActivityEntries = []; let activityFilter = "all"; function activityOutcomeView(outcome) { if (outcome === "hit") return { label: "Match", cls: "hit" }; if (outcome === "miss") return { label: "No Match", cls: "miss" }; if (outcome === "no_id") return { label: "No ID", cls: "no-id" }; if (outcome === "paused") return { label: "Paused", cls: "paused" }; return { label: outcome === "error" ? "Error" : (outcome || "Unknown"), cls: "error" }; } function activityMatchesFilter(entry) { if (activityFilter === "all") return true; if (activityFilter === "other") return !["hit", "miss", "no_id"].includes(entry.outcome); return entry.outcome === activityFilter; } function updateActivityFilterButtons() { document.querySelectorAll("#activity-filters .activity-filter").forEach((btn) => { btn.classList.toggle("active", btn.dataset.activityFilter === activityFilter); }); } function renderActivity(entries = recentActivityEntries) { recentActivityEntries = entries || []; const out = document.getElementById("activity-results"); if (!recentActivityEntries.length) { out.innerHTML = `no recent activity yet`; return; } const visible = recentActivityEntries.filter(activityMatchesFilter).slice(0, 20); if (!visible.length) { out.innerHTML = `no recent activity for this filter`; return; } out.innerHTML = visible.map((e) => { const outcome = activityOutcomeView(e.outcome); const id = e.id || "no ID"; const mode = e.mode ? ` · ${e.mode}` : ""; const timing = Number.isFinite(e.total_ms) ? ` · ${e.total_ms}ms total` : ""; const page = e.title || e.url || e.reason || ""; const reason = e.reason && e.reason !== page ? `
${escapeHtml(e.reason)}
` : ""; return `
${escapeHtml(outcome.label)} ${escapeHtml(id)} ${escapeHtml(e.trigger || "page")} · ${escapeHtml(fmtActivityWhen(e.ts))}${escapeHtml(mode)}${escapeHtml(timing)}
${escapeHtml(page)}
${reason}
`; }).join(""); } async function refreshActivity() { const out = document.getElementById("activity-results"); out.textContent = "loading activity..."; try { const r = await chrome.runtime.sendMessage({ type: "recent-activity" }); if (!r || !r.ok) { out.innerHTML = `error: ${escapeHtml(r?.error || "no response")}`; return; } renderActivity(r.entries || []); } catch (err) { out.innerHTML = `error: ${escapeHtml(err.message || String(err))}`; } } document.getElementById("activity-refresh").addEventListener("click", refreshActivity); document.getElementById("activity-clear").addEventListener("click", async () => { if (!confirm("Clear recent activity?")) return; await chrome.runtime.sendMessage({ type: "clear-recent-activity" }); renderActivity([]); }); document.getElementById("activity-filters").addEventListener("click", (e) => { const btn = e.target.closest(".activity-filter"); if (!btn) return; activityFilter = btn.dataset.activityFilter || "all"; updateActivityFilterButtons(); renderActivity(); }); updateActivityFilterButtons(); // ---------- search test bench ---------- function benchLookupRow(label, response) { if (!response || !response.ok) { const blocked = response?.paused ? "scanning paused" : response?.error || "no response"; return `
x${escapeHtml(label)}${escapeHtml(blocked)}
`; } const query = (response.queries || [])[0] || {}; const hitCount = Number(query.hits || response.hits || 0); const status = hitCount ? "ok" : "warn"; const sample = (query.structured || response.structured || []) .slice(0, 2) .map((row) => row.full_path || row.path || row.jav_id) .filter(Boolean) .join(" | "); const timing = response.timings?.host_rcjav_ms ?? response.timings?.native_ms ?? "?"; const detail = `${hitCount} hit(s) · ${timing}ms${sample ? ` · ${sample}` : ` · ${query.no_match_title || response.no_match_title || "No library hit"}`}`; return `
${hitCount ? "OK" : "!"}${escapeHtml(label)}${escapeHtml(detail)}
`; } function benchLookupHits(response) { if (!response || !response.ok) return null; const query = (response.queries || [])[0] || {}; return Number(query.hits || response.hits || 0); } async function runSearchBench() { const out = document.getElementById("search-bench-results"); const text = document.getElementById("search-bench-input").value.trim(); if (!text) { out.innerHTML = `paste an ID or text that contains one`; return; } out.textContent = "extracting ID..."; try { const extraction = await chrome.runtime.sendMessage({ type: "test-id-text", text, normalizers: readNormalizers(), }); if (!extraction || !extraction.ok) { out.innerHTML = `extract failed: ${escapeHtml(extraction?.error || "no response")}`; return; } const extracted = extraction.extracted || {}; const extractionRows = [ `
${extracted.id ? "OK" : "!"}Extracted ID${escapeHtml(extracted.id || "none")}
`, `
iRule${escapeHtml(extracted.source || "none")}${extracted.pattern ? ` · ${escapeHtml(extracted.pattern)}` : ""}${extracted.replacement ? ` -> ${escapeHtml(extracted.replacement)}` : ""}
`, ]; if (!extracted.id) { out.innerHTML = extractionRows.join("") + `
No lookup ran because the pasted sample did not extract an ID.
`; return; } out.innerHTML = extractionRows.join("") + `
Comparing LIVE and CACHE for ${escapeHtml(extracted.id)}...
`; const [settings, live, cached] = await Promise.all([ chrome.runtime.sendMessage({ type: "get-settings" }), chrome.runtime.sendMessage({ type: "bulk-query", queries: [extracted.id], quick: true }), chrome.runtime.sendMessage({ type: "bulk-query", queries: [extracted.id], quick: false }), ]); const liveHits = benchLookupHits(live); const cacheHits = benchLookupHits(cached); const mismatch = liveHits != null && cacheHits != null && liveHits !== cacheHits; const activeProfile = settings?.activeProfile || "config.json defaults"; out.innerHTML = [ ...extractionRows, `
iProfile${escapeHtml(activeProfile)}
`, benchLookupRow("LIVE", live), benchLookupRow("CACHE", cached), mismatch ? `
!MismatchLIVE and CACHE returned different hit counts. Check cache coverage/freshness and rebuild after ID-rule or library changes.
` : `
OKCompareLIVE and CACHE returned the same hit count.
`, `
Extraction uses the ID Rules currently shown on this page. Search lookups use saved host settings and the active profile.
`, ].join(""); } catch (err) { out.innerHTML = `test failed: ${escapeHtml(err.message || String(err))}`; } } document.getElementById("search-bench-run").addEventListener("click", runSearchBench); document.getElementById("search-bench-clear").addEventListener("click", () => { document.getElementById("search-bench-input").value = ""; document.getElementById("search-bench-results").innerHTML = ""; }); // ---------- bulk ID check ---------- function readBulkIds() { return [...new Set(document.getElementById("bulk-id-input").value .split(/[\s,]+/) .map((x) => x.trim()) .filter(Boolean))]; } function renderBulkResults(r) { const out = document.getElementById("bulk-id-results"); if (!r || !r.ok) { out.innerHTML = `error: ${escapeHtml(r?.error || "no response")}`; return; } const rows = [ `
Mode: ${escapeHtml(r.search_mode || "?")} · Queries: ${escapeHtml(r.query_count || 0)} · Hits: ${escapeHtml(r.hits || 0)} · Host: ${escapeHtml(r.timings?.host_rcjav_ms ?? "?")}ms
`, ]; for (const q of r.queries || []) { const hit = q.hits > 0; const sample = (q.structured || []).slice(0, 3).map((h) => h.full_path || h.path || h.jav_id).join(" | "); rows.push(`
${hit ? "HIT" : "MISS"} ${escapeHtml(q.query || "?")} · ${escapeHtml(q.hits || 0)} hit(s) ${sample ? `
${escapeHtml(sample)}
` : `
${escapeHtml(q.no_match_title || "No library hit")}
`}
`); } out.innerHTML = rows.join(""); } document.getElementById("bulk-id-run").addEventListener("click", async () => { const out = document.getElementById("bulk-id-results"); const queries = readBulkIds(); if (!queries.length) { out.innerHTML = `paste at least one ID`; return; } out.textContent = `checking ${queries.length} ID(s)...`; const r = await chrome.runtime.sendMessage({ type: "bulk-query", queries, quick: document.getElementById("quickMode").checked, }); renderBulkResults(r); }); document.getElementById("bulk-id-clear").addEventListener("click", () => { document.getElementById("bulk-id-input").value = ""; document.getElementById("bulk-id-results").innerHTML = ""; }); // ---------- duplicate review ---------- let lastDupeReview = null; function dupePath(row) { return row?.full_path || row?.path || row?.jav_id || "?"; } function _groupFmtKey(keep, deletions) { const all = [keep, ...deletions]; const exts = new Set(all.map(f => (f.path || f.full_path || "").split(".").pop().toLowerCase()).filter(e => e && e.length <= 4 && /^[a-z]+$/.test(e))); if (exts.has("mkv") && exts.has("mp4") && !exts.has("wmv") && !exts.has("avi")) return "MKV/MP4"; if (exts.has("wmv") && exts.has("mp4") && !exts.has("mkv")) return "WMV/MP4"; if (exts.has("avi") && exts.has("mp4") && !exts.has("mkv")) return "AVI/MP4"; if (exts.size === 1) return "Same format"; return null; // mixed/unusual — visible under All, no chip } function _pathRes(path) { if (/\[2160p\]/i.test(path) || /\b4[kK]\b/.test(path)) return 2160; if (/\[1080p\]/i.test(path)) return 1080; if (/\[720p\]/i.test(path)) return 720; if (/\[480p\]/i.test(path)) return 480; return 0; } function _groupResKey(keep, deletions) { const keepRes = _pathRes(dupePath(keep)); const maxDelRes = deletions.reduce((m, d) => Math.max(m, _pathRes(dupePath(d))), 0); if (keepRes === 0 && maxDelRes === 0) return "unknown"; if (keepRes === maxDelRes) return "same"; return keepRes > maxDelRes ? "upgrade" : "downgrade"; } let _drActiveFmt = "all"; let _drActiveRes = "all"; let _drActiveStatus = "all"; let _drActiveParts = "all"; let _drActiveVip = "all"; let _drActiveSearch = ""; function _drPromoteToKeep(row) { row.classList.remove("del", "confirmed", "unconfirmed", "queued"); row.classList.add("keep"); const tag = row.querySelector(".dr-tag"); tag.textContent = "KEEP"; tag.className = "dr-tag keep"; } function _drDemoteToDelete(row) { row.classList.remove("keep", "queued"); row.classList.add("del", "confirmed"); const tag = row.querySelector(".dr-tag"); tag.textContent = "DELETE?"; tag.className = "dr-tag del"; } function _drApplyFilters() { const wraps = document.querySelectorAll("#dupe-review-modal-body .dr-card-wrap"); for (const wrap of wraps) { const fmtMatch = _drActiveFmt === "all" || wrap.dataset.fmt === _drActiveFmt; const resMatch = _drActiveRes === "all" || wrap.dataset.res === _drActiveRes; let statusMatch = true; if (_drActiveStatus !== "all") { const skipped = wrap.classList.contains("skipped"); const delRows = wrap.querySelectorAll(".dr-row.del"); const doneRows = wrap.querySelectorAll(".dr-row.del.done"); const allDone = delRows.length > 0 && doneRows.length === delRows.length; if (_drActiveStatus === "skipped") statusMatch = skipped; else if (_drActiveStatus === "done") statusMatch = !skipped && allDone; else statusMatch = !skipped && !allDone; // pending } const partsMatch = _drActiveParts === "all" || wrap.dataset.parts === "1"; const vipMatch = _drActiveVip === "all" || wrap.dataset.vip === "1"; const q = _drActiveSearch; const searchMatch = !q || (wrap.querySelector(".dr-card-id")?.textContent.toLowerCase().includes(q)) || ([...wrap.querySelectorAll(".dr-path")].some(p => p.textContent.toLowerCase().includes(q))); wrap.classList.toggle("dr-hidden", !(fmtMatch && resMatch && statusMatch && partsMatch && vipMatch && searchMatch)); } } function _drBadges(keep, deletions, catalogs) { const all = [keep, ...deletions, ...catalogs]; const paths = all.map((f) => dupePath(f)); const out = []; if (paths.some((p) => /\[2160p\]/i.test(p) || /\b4k\b/i.test(p))) { out.push(`4K`); } else if (paths.some((p) => /\[1080p\]/i.test(p))) { out.push(`1080p`); } if (paths.some((p) => /clearjav/i.test(p))) { out.push(`CLEARJAV`); } const exts = new Set( all.map((f) => (f.path || f.full_path || "").split(".").pop().toLowerCase()).filter((e) => e && e.length <= 4) ); if (exts.size > 1) { out.push(`${escapeHtml([...exts].join("/").toUpperCase())}`); } else if (exts.has("mkv")) { out.push(`MKV`); } return out.join(""); } function renderDupeReview(r) { const out = document.getElementById("dupe-review-modal-body"); const summary = document.getElementById("dupe-review-results"); const exportBtn = document.getElementById("dupe-review-export"); if (!r || !r.ok) { lastDupeReview = null; exportBtn.disabled = true; out.innerHTML = `
Error: ${escapeHtml(r?.error || "no response")}
`; summary.innerHTML = out.innerHTML; openModal("dupe-review-modal"); return; } lastDupeReview = r; exportBtn.disabled = false; _drActiveFmt = "all"; _drActiveRes = "all"; _drActiveStatus = "all"; _drActiveParts = "all"; _drActiveVip = "all"; _drActiveSearch = ""; const groups = Object.entries(r.groups || {}); const totalCandidates = groups.reduce((n, [, g]) => n + (g.delete_candidates?.length || 0), 0); const roots = [ ...(r.roots?.source || []).map((root) => `source: ${root}`), ...(r.roots?.target || []).map((root) => `target: ${root}`), ]; // Compute per-group fmt/res keys and counts for filter bar const fmtCounts = {}; const resCounts = {}; let partsCount = 0; let vipCount = 0; let riskCount = 0; for (const [javId, g] of groups) { const fk = _groupFmtKey(g.keep || {}, g.delete_candidates || []); const rk = _groupResKey(g.keep || {}, g.delete_candidates || []); fmtCounts[fk] = (fmtCounts[fk] || 0) + 1; resCounts[rk] = (resCounts[rk] || 0) + 1; if (javId.includes("#part")) partsCount++; if ([g.keep, ...(g.delete_candidates || [])].some((row) => /(?:^|[\\/])clearjav(?:[\\/]|$)/i.test(dupePath(row)))) vipCount++; if ((g.risks || []).length) riskCount++; } const parts = []; // Filter bar (sticky top) if (groups.length) { const fmtOrder = ["MKV/MP4", "WMV/MP4", "AVI/MP4", "Same format"]; const resOrder = [ { key: "same", label: "Same res" }, { key: "upgrade", label: "Upgrade" }, ]; const fmtChips = fmtOrder .filter(k => fmtCounts[k]) .map(k => ``) .join(""); const resChips = resOrder .filter(({ key }) => resCounts[key]) .map(({ key, label }) => ``) .join(""); const totalGroups = groups.length; parts.push(`
Format: ${fmtChips} ${resChips.length ? `Resolution:${resChips}` : ""} Status: ${vipCount ? `` : ""} ${partsCount ? `` : ""}
`); } // Stats bar parts.push(`
${escapeHtml(r.potential_reclaim_human || "0 B")}
Recoverable
${escapeHtml(String(r.group_count || 0))}
Duplicate Groups
${escapeHtml(String(totalCandidates))}
Delete Candidates
`); if (riskCount) { parts.push(`
${escapeHtml(String(riskCount))} risky group${riskCount !== 1 ? "s" : ""} are skipped by default. Review part-like filenames before adding them back to the delete queue.
`); } // Roots hint if (roots.length) { parts.push(`
${escapeHtml(roots.join(" · "))}
`); } // Group cards if (!groups.length) { parts.push(`
No cached duplicate groups found.
`); } else { const cards = []; for (const [javId, group] of groups) { const keep = group.keep || {}; const deletions = group.delete_candidates || []; const catalogs = group.catalog || []; const reclaim = deletions.reduce((s, e) => s + (e.size || 0), 0); const reclaimHuman = deletions.length && deletions[0].size_human ? deletions.map((d) => d.size_human).join(" + ") : ""; const reclaimLabel = reclaimHuman ? `−${escapeHtml(reclaimHuman)}` : ""; const fmtKey = _groupFmtKey(keep, deletions); const resKey = _groupResKey(keep, deletions); const risks = group.risks || []; const keepReason = group.keep_reason?.summary || ""; const rows = []; if (risks.length) { rows.push(`
Review before deleting: ${risks.map((risk) => escapeHtml(risk.summary || "multipart risk")).join("
")}
`); } rows.push(`
KEEP ${escapeHtml(dupePath(keep))} ${keep.size_human ? `${escapeHtml(keep.size_human)}` : ""}
`); if (keepReason) { rows.push(`
Suggested KEEP reason: ${escapeHtml(keepReason)}
`); } for (const d of deletions) { rows.push(`
DELETE? ${escapeHtml(dupePath(d))} ${d.size_human ? `${escapeHtml(d.size_human)}` : ""}
`); } for (const c of catalogs) { rows.push(`
CATALOG ${escapeHtml(dupePath(c))} ${c.size_human ? `${escapeHtml(c.size_human)}` : ""}
`); } const hasClearJav = [keep, ...deletions].some((row) => /(?:^|[\\/])clearjav(?:[\\/]|$)/i.test(dupePath(row))); cards.push(`
${escapeHtml(javId)} ${_drBadges(keep, deletions, catalogs)} ${reclaimLabel}
${rows.join("")}
`); } parts.push(`
${cards.join("")}
`); } // Variant alerts — bare ID + variant coexist (e.g. IBW-902 and IBW-902z both present) const variantAlerts = r.variant_alerts || []; if (variantAlerts.length) { const alertCards = variantAlerts.map((alert) => { const rows = (alert.files || []).map((f) => { const detectedId = f.detected_id || f.jav_id || ""; const isVariant = detectedId !== alert.bare_id; const tag = isVariant ? `${escapeHtml(detectedId)}` : `BARE`; return `
${tag} ${escapeHtml(dupePath(f))} ${f.size_human ? `${escapeHtml(f.size_human)}` : ""}
`; }).join(""); return `
${escapeHtml(alert.bare_id)} ⚠ variant — manual review
${rows}
`; }).join(""); parts.push(`
⚠ ${variantAlerts.length} Variant Alert${variantAlerts.length !== 1 ? "s" : ""} — Same base ID, different product designator
${alertCards}
`); } if ((r.skipped || []).length) { const samples = (r.skipped || []).slice(0, 5) .map((s) => `
${escapeHtml(s.name || s.path || "?")} · ${escapeHtml(s.reason || "unparsed ID")}
`) .join(""); parts.push(`
Skipped ${escapeHtml(String(r.skipped.length))} path(s) with no parseable ID${samples ? ":" : "."}
${samples}`); } out.innerHTML = parts.join(""); summary.textContent = `${r.group_count || 0} cached duplicate group(s) reviewed. Results are open in the review window.`; openModal("dupe-review-modal"); } function _drUpdateExecuteBtn() { const confirmed = document.querySelectorAll("#dupe-review-modal-body .dr-card-wrap:not(.skipped):not(.dr-hidden) .dr-row.del.confirmed:not(.done)"); const btn = document.getElementById("dupe-review-execute"); const status = document.getElementById("dupe-review-confirm-status"); const n = confirmed.length; btn.textContent = `Execute Deletions (${n})`; btn.disabled = n === 0; status.textContent = n > 0 ? `${n} file${n !== 1 ? "s" : ""} queued for deletion — click to execute` : ""; } // Search input document.getElementById("dupe-review-modal-body").addEventListener("input", (e) => { if (e.target.id !== "dr-search") return; _drActiveSearch = e.target.value.trim().toLowerCase(); _drApplyFilters(); _drUpdateExecuteBtn(); }); // Filter chips + toggle DELETE? rows on click document.getElementById("dupe-review-modal-body").addEventListener("click", (e) => { // Filter chip const chip = e.target.closest(".dr-chip"); if (chip) { const ftype = chip.dataset.ftype; const fval = chip.dataset.fval; if (ftype === "fmt") { _drActiveFmt = fval; } else if (ftype === "res") { _drActiveRes = fval; } else if (ftype === "status") { _drActiveStatus = fval; } else if (ftype === "parts") { _drActiveParts = fval; } else if (ftype === "vip") { _drActiveVip = fval; } document.querySelectorAll(`#dupe-review-modal-body .dr-chip[data-ftype='${ftype}']`).forEach(c => { c.classList.toggle("active", c.dataset.fval === fval); }); _drApplyFilters(); _drUpdateExecuteBtn(); return; } // Search input if (e.target.id === "dr-search") return; // handled via input event below // Skip ear — toggle skipped on the wrap const ear = e.target.closest(".dr-skip-ear"); if (ear) { ear.closest(".dr-card-wrap").classList.toggle("skipped"); _drUpdateExecuteBtn(); return; } // Click KEEP row → full swap: this becomes DELETE?, pick a replacement KEEP const keepRow = e.target.closest(".dr-row.keep"); if (keepRow && !keepRow.classList.contains("done")) { const card = keepRow.closest(".dr-card"); // Prefer an unconfirmed del row as new KEEP (least disruptive), else first any del row const newKeep = card.querySelector(".dr-row.del.unconfirmed:not(.done)") || card.querySelector(".dr-row.del:not(.done)"); if (!newKeep) return; _drPromoteToKeep(newKeep); _drDemoteToDelete(keepRow); _drUpdateExecuteBtn(); return; } // DELETE? rows are not clickable — click the KEEP row to swap }); document.getElementById("dupe-review-execute").addEventListener("click", async () => { const rows = [...document.querySelectorAll("#dupe-review-modal-body .dr-card-wrap:not(.skipped):not(.dr-hidden) .dr-row.del.confirmed:not(.done)")]; if (!rows.length) return; const deleteRows = rows.filter((row) => row.dataset.fullPath); if (!deleteRows.length) return; const btn = document.getElementById("dupe-review-execute"); const status = document.getElementById("dupe-review-confirm-status"); const total = deleteRows.length; btn.disabled = true; let done = 0, failed = 0; for (const [index, row] of deleteRows.entries()) { const path = row.dataset.fullPath; status.textContent = `Deleting ${index + 1}/${total}...`; const res = await chrome.runtime.sendMessage({ type: "delete_batch", paths: [path] }); if (!res?.ok && res?.error === "deletion is disabled in options") { status.textContent = "Deletion is disabled - enable it in the Deletion tab first."; btn.disabled = false; return; } const r = (res?.results || [])[0] || { ok: false, error: res?.error || "delete failed" }; const tag = row.querySelector(".dr-tag"); if (r.ok) { row.classList.remove("confirmed"); row.classList.add("done"); tag.textContent = "DELETED"; done++; } else { row.classList.add("error"); tag.textContent = "ERROR"; row.title = r.error || "delete failed"; failed++; } } const parts = []; if (done) parts.push(`${done} deleted`); if (failed) parts.push(`${failed} failed`); status.textContent = parts.join(" · ") || "Nothing processed."; _drUpdateExecuteBtn(); }); document.getElementById("dupe-review-run").addEventListener("click", async () => { const out = document.getElementById("dupe-review-modal-body"); const executeBtn = document.getElementById("dupe-review-execute"); out.textContent = "reviewing cached duplicate groups..."; executeBtn.disabled = true; executeBtn.textContent = "Execute Deletions (0)"; document.getElementById("dupe-review-confirm-status").textContent = ""; openModal("dupe-review-modal"); renderDupeReview(await chrome.runtime.sendMessage({ type: "dupe-review" })); _drUpdateExecuteBtn(); }); document.getElementById("dupe-review-export").addEventListener("click", () => { if (!lastDupeReview) return; const blob = new Blob([JSON.stringify(lastDupeReview, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); const stamp = new Date().toISOString().replace(/[:.]/g, "-"); a.href = url; a.download = `rclone-jav-dupe-review-${stamp}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 0); }); for (const id of ["dupe-review-modal-close", "dupe-review-modal-done"]) { document.getElementById(id).addEventListener("click", () => closeModal("dupe-review-modal")); } document.getElementById("dupe-review-modal").addEventListener("click", (event) => { if (event.target.id === "dupe-review-modal") closeModal("dupe-review-modal"); }); // ---- Keep Ranking ---- const KR_DEFAULT_FMTS = ["mkv", "mp4", "wmv", "avi"]; const KR_DEFAULT_VIP_FOLDERS = ["ClearJAV"]; function _krWireDraggableList(list) { if (!list) return; let dragSrc = null; for (const item of list.querySelectorAll(".kr-fmt-item")) { item.addEventListener("dragstart", (e) => { dragSrc = item; item.classList.add("dragging"); e.dataTransfer.effectAllowed = "move"; }); item.addEventListener("dragend", () => { item.classList.remove("dragging"); list.querySelectorAll(".kr-fmt-item").forEach(i => i.classList.remove("drag-over")); list.querySelectorAll(".kr-fmt-item").forEach((el, idx) => { const pr = el.querySelector(".kr-fmt-priority"); if (pr) pr.textContent = `#${idx + 1}`; }); }); item.addEventListener("dragover", (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; if (item !== dragSrc) item.classList.add("drag-over"); }); item.addEventListener("dragleave", () => item.classList.remove("drag-over")); item.addEventListener("drop", (e) => { e.preventDefault(); item.classList.remove("drag-over"); if (dragSrc && dragSrc !== item) { const items = [...list.querySelectorAll(".kr-fmt-item")]; const srcIdx = items.indexOf(dragSrc); const dstIdx = items.indexOf(item); if (srcIdx < dstIdx) list.insertBefore(dragSrc, item.nextSibling); else list.insertBefore(dragSrc, item); } }); } } function _krRenderFmtList(fmts) { const list = document.getElementById("kr-fmt-list"); if (!list) return; list.innerHTML = fmts.map((fmt, i) => `
${escapeHtml(fmt)} #${i + 1}
` ).join(""); _krWireDraggableList(list); } function _krGetCurrentFmts() { return [...document.querySelectorAll("#kr-fmt-list .kr-fmt-item")] .map(el => el.dataset.fmt); } function _krRenderVipList(folders) { const list = document.getElementById("kr-vip-list"); if (!list) return; list.innerHTML = (folders || []).map((folder, i) => `
${escapeHtml(folder)} #${i + 1}
` ).join(""); for (const btn of list.querySelectorAll(".kr-vip-remove")) { btn.addEventListener("click", () => { btn.closest(".kr-fmt-item")?.remove(); list.querySelectorAll(".kr-fmt-item").forEach((el, idx) => { const pr = el.querySelector(".kr-fmt-priority"); if (pr) pr.textContent = `#${idx + 1}`; }); }); } _krWireDraggableList(list); } function _krGetVipFolders() { return [...document.querySelectorAll("#kr-vip-list .kr-fmt-item")] .map((el) => el.dataset.folder) .filter(Boolean); } function _krAddVipFolder() { const input = document.getElementById("kr-vip-add"); const folder = input?.value.trim(); if (!folder) return; const current = _krGetVipFolders(); if (!current.some((item) => item.toLowerCase() === folder.toLowerCase())) { _krRenderVipList([...current, folder]); } input.value = ""; } document.getElementById("kr-vip-add-btn")?.addEventListener("click", _krAddVipFolder); document.getElementById("kr-vip-add")?.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); _krAddVipFolder(); } }); async function loadKeepRanking() { try { const r = await chrome.runtime.sendMessage({ type: "get-keep-ranking" }); if (!r || !r.ok) return; const ranking = r.keep_ranking || {}; const toleranceEl = document.getElementById("kr-tolerance"); const resTagEl = document.getElementById("kr-res-tag"); const longerNameEl = document.getElementById("kr-longer-name"); if (toleranceEl) toleranceEl.value = ranking.size_tolerance_mib ?? 0; if (resTagEl) resTagEl.checked = ranking.tiebreak_res_tag !== false; if (longerNameEl) longerNameEl.checked = ranking.tiebreak_longer_name !== false; _krRenderVipList(ranking.priority_folders || KR_DEFAULT_VIP_FOLDERS); _krRenderFmtList(ranking.format_preference || KR_DEFAULT_FMTS); } catch (e) { // non-fatal — panel just shows defaults _krRenderVipList(KR_DEFAULT_VIP_FOLDERS); _krRenderFmtList(KR_DEFAULT_FMTS); } } document.getElementById("kr-save")?.addEventListener("click", async () => { const status = document.getElementById("kr-save-status"); const toleranceEl = document.getElementById("kr-tolerance"); const resTagEl = document.getElementById("kr-res-tag"); const longerNameEl = document.getElementById("kr-longer-name"); const tolerance = parseFloat(toleranceEl?.value ?? "0"); if (isNaN(tolerance) || tolerance < 0) { status.textContent = "Size tolerance must be 0 or a positive number."; status.className = "kr-save-status err"; return; } const ranking = { priority_folders: _krGetVipFolders(), size_tolerance_mib: tolerance, format_preference: _krGetCurrentFmts(), tiebreak_res_tag: resTagEl?.checked !== false, tiebreak_longer_name: longerNameEl?.checked !== false, }; status.textContent = "Saving…"; status.className = "kr-save-status"; try { const r = await chrome.runtime.sendMessage({ type: "save-keep-ranking", keep_ranking: ranking }); if (r?.ok) { status.textContent = "Saved — next dupe review will use the updated ranking."; status.className = "kr-save-status ok"; } else { status.textContent = "Error: " + (r?.error || "unknown"); status.className = "kr-save-status err"; } } catch (e) { status.textContent = "Error: " + e.message; status.className = "kr-save-status err"; } }); // Load on page open loadKeepRanking(); // ---- Library Issues ---- let lastLibraryIssues = null; let _libraryIssuesDirty = false; function renderLibraryIssues(r) { const out = document.getElementById("library-issues-modal-body"); const statusEl = document.getElementById("library-issues-results"); const renameAllBtn = document.getElementById("library-issues-rename-all"); const renameStatus = document.getElementById("library-issues-rename-status"); if (!r || !r.ok) { lastLibraryIssues = null; renameAllBtn.disabled = true; out.innerHTML = `
Error: ${escapeHtml(r?.error || "no response")}
`; openModal("library-issues-modal"); return; } lastLibraryIssues = r; const brackets = r.bracket_names || []; const nohyphens = r.nohyphen_names || []; const total = brackets.length + nohyphens.length; renameAllBtn.disabled = total === 0; renameStatus.textContent = ""; const parts = []; if (!total) { parts.push(`
✓ No library issues found. All filenames are canonical.
`); } else { parts.push(`
${total} file${total !== 1 ? "s" : ""} with non-canonical names — ${brackets.length} bracket-wrapped, ${nohyphens.length} no-hyphen
`); const makeRow = (entry, tagClass, tagLabel) => { const fname = entry.path.split("/").pop(); const dir = entry.path.lastIndexOf("/") !== -1 ? entry.path.slice(0, entry.path.lastIndexOf("/") + 1) : ""; return `
${tagLabel}
${escapeHtml(fname)} → ${escapeHtml(entry.canonical_name)}
${escapeHtml(entry.size_human || "")}
`; }; if (brackets.length) { parts.push(`
Bracket-wrapped IDs (${brackets.length})
`); parts.push(brackets.map((e) => makeRow(e, "bracket", "[ ]")).join("")); } if (nohyphens.length) { parts.push(`
No-hyphen IDs (${nohyphens.length})
`); parts.push(nohyphens.map((e) => makeRow(e, "nohyphen", "no hyphen")).join("")); } } out.innerHTML = parts.join(""); statusEl.textContent = total ? `${total} library issue(s) found. Review window is open.` : "No library issues found."; openModal("library-issues-modal"); // Per-row rename buttons out.querySelectorAll(".li-rename-btn").forEach((btn) => { btn.addEventListener("click", async () => { const row = btn.closest(".li-row"); const remote = row.dataset.remote; const oldPath = row.dataset.old; const newPath = row.dataset.new; btn.disabled = true; btn.textContent = "…"; const res = await chrome.runtime.sendMessage({ type: "rename_file", remote, old_path: oldPath, new_path: newPath, }); const tag = row.querySelector(".li-tag"); if (res?.ok) { tag.className = "li-tag done"; tag.textContent = "✓"; btn.textContent = "Done"; row.querySelector(".li-old").style.textDecoration = "line-through"; _libraryIssuesDirty = true; } else if (res?.conflict) { tag.className = "li-tag conflict"; tag.textContent = "conflict"; btn.textContent = "Skip"; renameStatus.textContent = `Conflict: ${res.error || "target exists"}`; } else { tag.className = "li-tag conflict"; tag.textContent = "error"; btn.textContent = "Error"; renameStatus.textContent = res?.error || "rename failed"; } }); }); } document.getElementById("library-issues-run").addEventListener("click", async () => { const out = document.getElementById("library-issues-modal-body"); out.innerHTML = `
Loading library issues from cache…
`; openModal("library-issues-modal"); renderLibraryIssues(await chrome.runtime.sendMessage({ type: "library_issues" })); }); document.getElementById("library-issues-rename-all").addEventListener("click", async () => { const rows = [...document.querySelectorAll("#library-issues-modal-body .li-row")]; const renameStatus = document.getElementById("library-issues-rename-status"); const renameAllBtn = document.getElementById("library-issues-rename-all"); // Collect pending renames (skip already-done or disabled rows) const pending = rows.reduce((acc, row) => { const btn = row.querySelector(".li-rename-btn"); if (!btn || btn.disabled) return acc; acc.push({ row, remote: row.dataset.remote, old_path: row.dataset.old, new_path: row.dataset.new }); return acc; }, []); if (!pending.length) return; renameAllBtn.disabled = true; renameStatus.textContent = `Renaming ${pending.length} file(s)…`; const renames = pending.map(({ remote, old_path, new_path }) => ({ remote, old_path, new_path })); const res = await chrome.runtime.sendMessage({ type: "rename_files_batch", renames }); const results = res?.results || []; let done = 0, conflicts = 0, errors = 0; results.forEach((r, i) => { const { row } = pending[i]; const tag = row.querySelector(".li-tag"); const btn = row.querySelector(".li-rename-btn"); if (r.ok) { tag.className = "li-tag done"; tag.textContent = "✓"; btn.disabled = true; btn.textContent = "Done"; row.querySelector(".li-old").style.textDecoration = "line-through"; done++; } else if (r.conflict) { tag.className = "li-tag conflict"; tag.textContent = "conflict"; btn.disabled = false; btn.textContent = "Skip"; conflicts++; } else { tag.className = "li-tag conflict"; tag.textContent = "error"; btn.disabled = false; btn.textContent = "Error"; errors++; } }); const parts = []; if (done) parts.push(`${done} renamed`); if (conflicts) parts.push(`${conflicts} conflict(s)`); if (errors) parts.push(`${errors} error(s)`); renameStatus.textContent = parts.join(" · ") || "Nothing to rename."; renameAllBtn.disabled = false; _libraryIssuesDirty = done > 0; }); function _closeLibraryIssues() { closeModal("library-issues-modal"); if (_libraryIssuesDirty) { _libraryIssuesDirty = false; chrome.runtime.sendMessage({ type: "library_issues" }, (r) => { if (!r || !r.ok) return; const total = (r.bracket_names?.length || 0) + (r.nohyphen_names?.length || 0); document.getElementById("library-issues-results").textContent = total ? `${total} library issue(s) found. Review window is open.` : "No library issues found."; }); } } for (const id of ["library-issues-modal-close", "library-issues-modal-done"]) { document.getElementById(id).addEventListener("click", _closeLibraryIssues); } document.getElementById("library-issues-modal").addEventListener("click", (e) => { if (e.target.id === "library-issues-modal") _closeLibraryIssues(); }); (function () { const rebuildBtn = document.getElementById("cache-rebuild-run"); const rebuildMode = document.getElementById("cache-rebuild-mode"); const cacheStatusOut = document.getElementById("cache-status-results"); const scanJobOut = document.getElementById("scan-job-results"); let _optScanTimer = null; let _optScanning = false; const _stopOptPoll = () => { if (_optScanTimer) { clearInterval(_optScanTimer); _optScanTimer = null; } }; function _setOptScanningState(scanning) { _optScanning = scanning; rebuildBtn.textContent = scanning ? "✕ Cancel" : "Rebuild Cache"; if (rebuildMode) rebuildMode.disabled = scanning; rebuildBtn.style.background = scanning ? "#3a1a1a" : ""; rebuildBtn.style.borderColor = scanning ? "#722" : ""; rebuildBtn.style.color = scanning ? "#faa" : ""; } function _scanStatus(r) { if (!r || r.no_state) return "idle"; if (r.scanning && !r.done) return "running"; if (r.cancelled) return "cancelled"; if (r.scan_ok === false) return "failed"; if (r.done) return "completed"; return "idle"; } function _renderScanJob(r) { if (!r || r.no_state) { scanJobOut.innerHTML = `no scan job recorded yet`; return; } const status = _scanStatus(r); const pillCls = status === "completed" ? "ok" : status === "failed" ? "fail" : ""; const jobLabel = status === "running" ? "Current Scan Job" : "Last Scan Job"; const mode = r.scan_since ? `incremental ${r.scan_since}` : "full"; const scope = (r.scope && r.scope.length) ? r.scope.join(", ") : "configured scan roots"; const finished = r.finished_at || r.started_at || ""; const when = finished ? new Date(finished).toLocaleString() : ""; const elapsed = r.elapsed_s != null ? `${Number(r.elapsed_s).toFixed(1)}s` : ""; const count = r.file_count != null ? `${Number(r.file_count).toLocaleString()} files` : ""; const summary = [mode, scope, count, elapsed].filter(Boolean).join(" · "); const jobs = (r.remote_jobs && r.remote_jobs.length) ? r.remote_jobs : (r.remotes || []).map((remote, i) => ({ remote, status: remote === r.current_remote ? status : i < (r.current_index || 0) ? "completed" : "queued", files: remote === r.current_remote ? r.files_this_remote : null, total: remote === r.current_remote ? r.files_remote_total : null, })); const jobRoots = jobs.map((j) => j.remote).filter(Boolean); const retiredRoots = _configuredScanRoots.length ? jobRoots.filter((root) => !_configuredScanRoots.includes(root)) : []; const jobRows = jobs.map((j) => { const files = Number.isFinite(j.files) ? Number(j.files).toLocaleString() : "?"; const total = Number.isFinite(j.total) ? Number(j.total).toLocaleString() : ""; const pct = Number.isFinite(j.files) && Number.isFinite(j.total) && j.total > 0 ? Math.min(100, Math.round((j.files / j.total) * 100)) : null; const detail = [ j.label, j.incremental ? "incremental" : "", `${files}${total ? ` / ${total}` : ""} files`, Number.isFinite(j.skipped) && j.skipped ? `${j.skipped} skipped` : "", ].filter(Boolean).join(" · "); return `
${escapeHtml(j.remote || "?")} · ${escapeHtml(j.status || "queued")}
${escapeHtml(detail)}
${pct != null ? `
` : ""}
`; }).join(""); scanJobOut.innerHTML = `
${escapeHtml(jobLabel)}${when ? ` · ${escapeHtml(when)}` : ""}
${escapeHtml(status)}${escapeHtml(summary || "scan job")}
${retiredRoots.length ? `
Historical scan roots not in current config: ${escapeHtml(retiredRoots.join(", "))}. They are shown because this job was recorded before the scan roots changed.
` : ""} ${r.error ? `
${escapeHtml(r.error)}
` : ""} ${jobRows || `
waiting for remote progress...
`} `; } const _pollOptProgress = () => { chrome.runtime.sendMessage({ type: "scan-progress" }, (r) => { if (chrome.runtime.lastError || !r || !r.ok) return; _renderScanJob(r); if (r.done || !r.scanning) { _stopOptPoll(); _setOptScanningState(false); if (r.cancelled) { return; } else if (r.scan_ok !== false) { setTimeout(() => document.getElementById("cache-status-run").click(), 500); } return; } }); }; async function _refreshScanJob() { try { const cache = await chrome.runtime.sendMessage({ type: "cache-status" }); if (cache && cache.ok) rememberConfiguredScanRoots(cache); } catch {} _pollOptProgress(); } async function _startOptScan(scanRoots = [], forceSince = null) { const out = scanJobOut; if (_optScanning) { // Cancel in-progress scan rebuildBtn.disabled = true; rebuildBtn.textContent = "Cancelling…"; chrome.runtime.sendMessage({ type: "scan-cancel" }, () => { rebuildBtn.disabled = false; // State will update on next poll tick }); return; } // forceSince overrides dropdown (used by per-remote Refresh to stay incremental) const scanSince = forceSince !== null ? forceSince : (rebuildMode ? rebuildMode.value : ""); const scope = scanRoots.length ? `refresh ${scanRoots.join(", ")}` : "all configured scan roots"; const label = scanSince ? `incrementally update files changed in the last ${scanSince}` : "fully rebuild"; const button = scanRoots.length ? "Refresh" : "Rebuild"; if (!confirm(`${button} cache now?\n\nScope: ${scope}\nMode: ${label}\n\nThis can take several minutes.`)) return; cacheStatusOut.innerHTML = `starting scan…`; out.innerHTML = ""; try { const r = await chrome.runtime.sendMessage({ type: "run-scan", scanSince, scanRoots }); if (!r || !r.ok) { out.innerHTML = `scan failed: ${escapeHtml(r?.error || "no response")}`; return; } _setOptScanningState(true); _pollOptProgress(); _optScanTimer = setInterval(_pollOptProgress, 1500); } catch (err) { out.innerHTML = `scan failed: ${escapeHtml(err.message || String(err))}`; } } rebuildBtn.addEventListener("click", () => _startOptScan()); function _renderNonJavPanel(items, remote) { const panel = document.createElement("div"); panel.className = "nonjav-panel"; panel.dataset.remote = remote; const deleteEnabled = document.getElementById("enableDelete")?.checked; const delBtnHtml = deleteEnabled ? `` : `Enable deletion in settings to delete`; panel.innerHTML = `
${escapeHtml(remote)} · ${items.length} non-JAV file${items.length !== 1 ? "s" : ""} ${delBtnHtml}
${items.map(f => `
${escapeHtml(f.ext || "?")} ${escapeHtml(f.path)} ${deleteEnabled ? `` : ""}
`).join("")}
`; // Delete one panel.addEventListener("click", async (e) => { const btn = e.target.closest(".nonjav-del-one"); if (btn) { const item = btn.closest(".nonjav-item"); const path = item?.dataset.fullPath; if (!path) return; if (!confirm(`Delete?\n${path}`)) return; btn.disabled = true; const r = await chrome.runtime.sendMessage({ type: "delete-skipped", paths: [path] }); if (r?.ok) { item.classList.add("deleted"); item.querySelector(".nonjav-del-one")?.remove(); _updateNonJavDelAll(panel); } else { btn.disabled = false; panel.querySelector(".nonjav-status").textContent = "Error: " + (r?.error || "failed"); } return; } const delAll = e.target.closest(".nonjav-del-all"); if (delAll) { const allItems = [...panel.querySelectorAll(".nonjav-item:not(.deleted)")]; const paths = allItems.map(i => i.dataset.fullPath).filter(Boolean); if (!paths.length) return; if (!confirm(`Delete all ${paths.length} non-JAV file(s) from ${remote}?`)) return; delAll.disabled = true; const statusEl = panel.querySelector(".nonjav-status"); statusEl.textContent = `Deleting ${paths.length} file(s)…`; const r = await chrome.runtime.sendMessage({ type: "delete-skipped", paths }); const ok = r?.deleted_count || 0; const fail = r?.failed_count || 0; if (ok) { // Mark successfully deleted items const deletedPaths = new Set( (r.results || []).filter(x => x.ok).map(x => x.path) ); allItems.forEach(i => { if (deletedPaths.has(i.dataset.fullPath)) { i.classList.add("deleted"); i.querySelector(".nonjav-del-one")?.remove(); } }); _updateNonJavDelAll(panel); } statusEl.textContent = fail ? `Deleted ${ok}, failed ${fail}. Check deletion settings.` : `Deleted ${ok} file(s).`; } }); return panel; } function _updateNonJavDelAll(panel) { const remaining = panel.querySelectorAll(".nonjav-item:not(.deleted)").length; const btn = panel.querySelector(".nonjav-del-all"); if (btn) { btn.textContent = `Delete All (${remaining})`; btn.disabled = remaining === 0; } } cacheStatusOut.addEventListener("click", (event) => { const showSkipped = event.target.closest(".cache-show-skipped"); if (showSkipped) { const remote = showSkipped.dataset.remote; // Toggle: if panel already open, close it const existing = cacheStatusOut.querySelector(`.nonjav-panel[data-remote="${CSS.escape(remote)}"]`); if (existing) { existing.remove(); showSkipped.textContent = showSkipped.textContent.replace("▴", "▾"); return; } showSkipped.textContent = showSkipped.textContent.replace("▾", "▴"); // Find skipped items from last cache status result const items = (_cacheSkippedByRemote?.get(remote)) || []; const panel = _renderNonJavPanel(items, remote); // Insert after the row containing this button showSkipped.closest("div")?.after(panel); return; } const refresh = event.target.closest(".cache-refresh-remote"); if (refresh) { const remote = refresh.dataset.remote || ""; if (!remote) return; // Per-remote Refresh is always incremental — inherit dropdown value if it's a // duration (not "Full Rebuild"), otherwise default to 24h. const dropdownVal = rebuildMode ? rebuildMode.value : ""; const refreshSince = dropdownVal || "24h"; _startOptScan([remote], refreshSince); return; } }); document.getElementById("scan-job-clear").addEventListener("click", async () => { if (!confirm("Clear recorded scan job history?\n\nThis only clears the Scan Job panel state. It does not change cache.json.")) return; scanJobOut.textContent = "clearing scan job history..."; const r = await chrome.runtime.sendMessage({ type: "scan-clear" }); if (!r || !r.ok) { scanJobOut.innerHTML = `clear failed: ${escapeHtml(r?.error || "no response")}`; return; } _renderScanJob({ ok: true, no_state: true }); }); // If Options is opened while a scan is already running, attach to it instead // of showing an idle Rebuild button. _refreshScanJob(); chrome.runtime.sendMessage({ type: "scan-progress" }, (r) => { if (chrome.runtime.lastError || !r || !r.ok) return; _renderScanJob(r); if (!r.scanning) return; _setOptScanningState(true); _optScanTimer = setInterval(_pollOptProgress, 1500); }); })(); // ---------- adapters ---------- function renderAdapters(list) { const tbody = document.querySelector("#adapters tbody"); tbody.innerHTML = ""; for (const a of list) addAdapterRow(a.host || "", a.selector || ""); if (list.length === 0) addAdapterRow("", ""); } function addAdapterRow(host, selector) { const tbody = document.querySelector("#adapters tbody"); const tr = document.createElement("tr"); tr.innerHTML = ` `; tr.querySelector(".host").value = host; tr.querySelector(".selector").value = selector; tr.querySelector(".del").addEventListener("click", () => tr.remove()); tbody.appendChild(tr); } function readAdapters() { const rows = document.querySelectorAll("#adapters tbody tr"); const out = []; for (const tr of rows) { const host = tr.querySelector(".host").value.trim(); const selector = tr.querySelector(".selector").value.trim(); if (host && selector) out.push({ host, selector }); } return out; } document.getElementById("add-adapter").addEventListener("click", () => addAdapterRow("", "")); document.getElementById("validate-adapters").addEventListener("click", () => { const status = document.getElementById("picker-status"); const rows = [...document.querySelectorAll("#adapters tbody tr")]; const seen = new Set(); const issues = []; for (const tr of rows) { const host = tr.querySelector(".host").value.trim(); const selector = tr.querySelector(".selector").value.trim(); tr.style.outline = ""; if (!host && !selector) continue; if (!host || !selector) { issues.push("rows need both host and selector"); tr.style.outline = "1px solid #775"; continue; } const key = host.toLowerCase(); if (seen.has(key)) { issues.push(`duplicate host: ${host}`); tr.style.outline = "1px solid #775"; } seen.add(key); try { document.querySelector(selector); } catch { issues.push(`invalid CSS selector for ${host}`); tr.style.outline = "1px solid #775"; } } status.textContent = issues.length ? [...new Set(issues)].join("; ") : `${readAdapters().length} adapter row(s) look valid`; updateSectionSummaries(); }); // ---------- ID normalizers ---------- function renderNormalizers(list) { const tbody = document.querySelector("#normalizers tbody"); tbody.innerHTML = ""; for (const n of list) addNormalizerRow(n.re || "", n.fmt || ""); if (list.length === 0) addNormalizerRow("", ""); } function addNormalizerRow(re, fmt) { const tbody = document.querySelector("#normalizers tbody"); const tr = document.createElement("tr"); tr.innerHTML = ` `; tr.querySelector(".re").value = re; tr.querySelector(".fmt").value = fmt; tr.querySelector(".del").addEventListener("click", () => tr.remove()); tbody.appendChild(tr); } function readNormalizers() { const rows = document.querySelectorAll("#normalizers tbody tr"); const out = []; for (const tr of rows) { const re = tr.querySelector(".re").value.trim(); const fmt = tr.querySelector(".fmt").value.trim(); if (re && fmt) out.push({ re, fmt }); } return out; } document.getElementById("add-normalizer").addEventListener("click", () => addNormalizerRow("", "")); document.getElementById("validate-normalizers").addEventListener("click", () => { const status = document.getElementById("normalizer-status"); const rows = [...document.querySelectorAll("#normalizers tbody tr")]; const issues = []; for (const tr of rows) { tr.style.outline = ""; const re = tr.querySelector(".re").value.trim(); const fmt = tr.querySelector(".fmt").value.trim(); if (!re && !fmt) continue; if (!re || !fmt) { issues.push("rows need both regex and replacement"); tr.style.outline = "1px solid #775"; continue; } try { new RegExp(re, "i"); } catch (err) { issues.push(`invalid regex: ${err.message}`); tr.style.outline = "1px solid #775"; } } status.textContent = issues.length ? issues.join("; ") : `${readNormalizers().length} normalizer row(s) look valid`; updateSectionSummaries(); }); // ---------- custom part detectors ---------- const PART_DETECTOR_SAMPLES = [ "KV-118 - Aiba Reika_PART1.mp4", "KV-118 - Aiba Reika_PART2.mp4", "KV-118 - Aiba Reika_PART3.mp4", "KV-118_1.mp4", "KV-118_2.mp4", "KV-118-pt1.mp4", "KV-118-part2.mp4", "KV-118-cd1.mp4", "KV-118-disc2.mp4", "KV-118 (1).mp4", "KV-118 (1 of 3).mp4", "KV-118.1of3.mp4", "KV-118-2 of 4.mp4", "OFJE-195-1 [480p].mp4", "OFJE-195-2 [480p].mp4", "OFJE-195-3 [480p].mp4", "KV-118_A.mp4", "KV-118-B.mp4", "KV-118A.mp4", "KV-118 1.mp4", "KV-118-P1.mp4", "KV-118_P2.mp4", "KV-118 Part 3.mp4", "KV-118_EP1.mp4", "KV-118 Episode 2.mp4", "KV-118_Vol1.mp4", "KV-118 Volume 2.mp4", "KV-118_Scene1.mp4", "KV-118_Side-A.mp4", ]; const BUILTIN_PART_DETECTORS = [ { pattern: "[-_ ](?:pt|part|cd|disc)[-_ ]?(\\d+)$", note: "pt / part / cd / disc number" }, { pattern: "\\s*\\((\\d+)(?:\\s*of\\s*\\d+)?\\)$", note: "parenthesized part number or X of Y" }, { pattern: "[._ -](\\d+)\\s*of\\s*\\d+$", note: "X of Y suffix" }, { pattern: "_(\\d{1,2})$", note: "underscore number" }, { pattern: "-(\\d{1,2})$", note: "hyphen short part number" }, { pattern: "[-_]([A-D])$", note: "lettered part with separator" }, { pattern: "(?<=\\d)([A-D])$", note: "lettered part directly after ID" }, { pattern: "\\s+(\\d{1,2})$", note: "trailing spaced number" }, ]; function partDetectorStem(filename) { return filename.replace(/\.[^.]+$/, ""); } function partDetectorStemStages(filename) { const raw = partDetectorStem(filename); const resolutionClean = raw.replace(/\s*\[[^\]]*\]\s*$/, "").trim(); let actressClean = resolutionClean; if (actressClean.includes(" - ")) actressClean = actressClean.slice(0, actressClean.indexOf(" - ")).trim(); const stages = []; for (const [label, stem] of [ ["raw stem", raw], ["after trailing metadata cleanup", resolutionClean], ["after actress cleanup", actressClean], ]) { if (stem && !stages.some((stage) => stage.stem === stem)) stages.push({ label, stem }); } return stages; } function partDetectorRegex(pattern) { // Custom detectors are Python regexes, but the common detector subset is // shared with browser RegExp. Preview the representative shapes here; rc-jav // remains authoritative when the saved rule runs during scan/search. return new RegExp(pattern, "i"); } function builtinPartCoverage(filename) { for (const detector of BUILTIN_PART_DETECTORS) { try { const re = partDetectorRegex(detector.pattern); for (const stage of partDetectorStemStages(filename)) { const match = stage.stem.match(re); if (match && match[1]) return detector; } } catch {} } return null; } function updatePartDetectorFeedback(row) { const feedback = row.querySelector(".part-detector-feedback"); const pattern = row.querySelector(".part-detector-pattern").value.trim(); if (!pattern) { feedback.innerHTML = `Enter a detector regex. Capture group 1 should be the part token.`; return; } let re; try { re = partDetectorRegex(pattern); } catch (err) { feedback.innerHTML = `Invalid preview regex: ${escapeHtml(err.message || String(err))}`; return; } const matches = []; let missingCapture = false; for (const filename of PART_DETECTOR_SAMPLES) { for (const stage of partDetectorStemStages(filename)) { const match = stage.stem.match(re); if (!match) continue; if (!match[1]) missingCapture = true; matches.push({ filename, part: match[1] || "?", stage: stage.label }); break; } } if (!matches.length) { feedback.innerHTML = `No representative sample matched. The rule may still be valid for a library-specific filename shape.`; return; } const isBuiltin = row.classList.contains("builtin"); const covered = !isBuiltin ? matches.map((item) => ({ item, detector: builtinPartCoverage(item.filename) })) : []; const alreadyCovered = covered.length && covered.every((entry) => entry.detector); const coveredNote = alreadyCovered ? `
These representative matches are already covered by built-in detector${new Set(covered.map((entry) => entry.detector.pattern)).size === 1 ? "" : "s"}.
` : ""; feedback.innerHTML = [ `${missingCapture ? "Matched, but capture group 1 was missing for a sample." : `Matches ${matches.length} representative filename shape${matches.length === 1 ? "" : "s"}.`}`, coveredNote, ...matches.slice(0, 4).map((item) => `
${escapeHtml(item.filename)} -> part ${escapeHtml(item.part)} (${escapeHtml(item.stage)})
`), matches.length > 4 ? `
and ${escapeHtml(matches.length - 4)} more representative match(es)
` : "", ].filter(Boolean).join(""); } function addPartDetectorRow(pattern = "", { builtin = false, note = "" } = {}) { const list = document.getElementById(builtin ? "builtin-part-detectors" : "part-detectors"); const row = document.createElement("div"); row.className = "part-detector-row" + (builtin ? " builtin" : ""); row.innerHTML = `
${builtin ? `Built in` : ``}
${note ? `
${escapeHtml(note)}
` : ""}
`; row.querySelector(".part-detector-pattern").value = pattern; if (!builtin) { row.querySelector(".part-detector-pattern").addEventListener("input", () => { updatePartDetectorFeedback(row); updateSectionSummaries(); }); row.querySelector("button").addEventListener("click", () => { row.remove(); if (!list.children.length) addPartDetectorRow(""); updateSectionSummaries(); }); } list.appendChild(row); updatePartDetectorFeedback(row); return row; } function renderPartDetectors(patterns) { const builtinList = document.getElementById("builtin-part-detectors"); const list = document.getElementById("part-detectors"); builtinList.innerHTML = ""; list.innerHTML = ""; for (const detector of BUILTIN_PART_DETECTORS) addPartDetectorRow(detector.pattern, { builtin: true, note: detector.note }); for (const pattern of patterns || []) addPartDetectorRow(pattern); if (!list.children.length) addPartDetectorRow(""); } function readPartDetectors() { return [...document.querySelectorAll("#part-detectors .part-detector-pattern")] .map((input) => input.value.trim()) .filter(Boolean); } document.getElementById("add-part-detector").addEventListener("click", () => { addPartDetectorRow("").querySelector(".part-detector-pattern").focus(); }); // Tester document.getElementById("norm-test-run").addEventListener("click", async () => { const input = document.getElementById("norm-test-in").value; const out = document.getElementById("norm-test-out"); out.textContent = "testing text..."; try { const r = await chrome.runtime.sendMessage({ type: "test-id-text", text: input, normalizers: readNormalizers(), }); if (!r || !r.ok) { out.innerHTML = `error: ${escapeHtml(r?.error || "no response")}`; return; } const e = r.extracted || {}; out.innerHTML = [ `
ID: ${escapeHtml(e.id || "none")}
`, `
Rule: ${escapeHtml(e.source || "none")}
`, e.pattern ? `
Pattern: ${escapeHtml(e.pattern)}
` : "", e.replacement ? `
Replacement: ${escapeHtml(e.replacement)}
` : "", e.raw ? `
Raw: ${escapeHtml(e.raw)}
` : "", ].filter(Boolean).join(""); } catch (err) { out.innerHTML = `error: ${escapeHtml(err.message || String(err))}`; } }); // ---------- element picker ---------- // Track the active picker poll so re-clicking "Pick Element" cancels the prior // poll instead of leaving two racing on the same session-storage key. let activePickerPoll = null; window.addEventListener("pagehide", () => { if (activePickerPoll != null) { clearInterval(activePickerPoll); activePickerPoll = null; } }); async function startPicker() { const status = document.getElementById("picker-status"); if (activePickerPoll != null) { clearInterval(activePickerPoll); activePickerPoll = null; // Drop any stale prior result so the new poll doesn't see it. await chrome.storage.session.remove("lastPickerResult"); } status.textContent = "starting picker…"; const resp = await chrome.runtime.sendMessage({ type: "start-picker", from: "options" }); if (!resp || !resp.ok) { status.textContent = "error: " + (resp?.error || "no response"); return; } status.textContent = `picker armed on: ${resp.url || "(unknown tab)"} — click an element, Esc to cancel`; const start = Date.now(); const poll = setInterval(async () => { const { lastPickerResult } = await chrome.storage.session.get("lastPickerResult"); if (lastPickerResult && lastPickerResult.ts > start) { clearInterval(poll); activePickerPoll = null; await chrome.storage.session.remove("lastPickerResult"); if (lastPickerResult.type === "picker-cancelled") { status.textContent = "cancelled"; return; } const host = lastPickerResult.host || ""; const hostPattern = host ? host.replace(/^www\./, "") : ""; const selector = lastPickerResult.selector || ""; const rows = document.querySelectorAll("#adapters tbody tr"); let replaced = false; for (const tr of rows) { const existing = tr.querySelector(".host").value.trim().toLowerCase(); if (existing === hostPattern.toLowerCase()) { tr.querySelector(".selector").value = selector; replaced = true; break; } } if (!replaced) addAdapterRow(hostPattern, selector); for (const tr of document.querySelectorAll("#adapters tbody tr")) { const h = tr.querySelector(".host").value.trim(); const s = tr.querySelector(".selector").value.trim(); if (!h && !s) tr.remove(); } status.textContent = `${replaced ? "updated" : "added"}: ${selector} (sample: "${(lastPickerResult.sample || "").slice(0, 60)}", detected: ${lastPickerResult.detectedId || "no ID"})`; } if (Date.now() - start > 120000) { clearInterval(poll); activePickerPoll = null; status.textContent = "timed out (2 min)"; } }, 500); activePickerPoll = poll; } document.getElementById("pick-element").addEventListener("click", startPicker); document.getElementById("test-active-page").addEventListener("click", async () => { const wrap = document.getElementById("adapter-test-result"); const out = document.getElementById("adapter-test-output"); wrap.style.display = ""; out.textContent = "testing active page..."; try { const r = await chrome.runtime.sendMessage({ type: "test-active-page", adapters: readAdapters(), normalizers: readNormalizers(), }); if (!r || !r.ok) { out.innerHTML = `error: ${escapeHtml(r?.error || "no response")}`; return; } const e = r.extracted || {}; const selected = e.selected || {}; const stage = (name, value) => `
${escapeHtml(name)}: ${escapeHtml(value?.id || "none")}${value?.raw ? ` · ${escapeHtml(value.raw)}` : ""}
`; out.innerHTML = [ `
ID: ${escapeHtml(e.id || "none")}
`, `
Source: ${escapeHtml(e.source || "none")}
`, selected.adapter ? `
Adapter: ${escapeHtml(selected.adapter)}
` : "", selected.selector ? `
Selector: ${escapeHtml(selected.selector)}
` : "", selected.raw ? `
Selected raw: ${escapeHtml(selected.raw)}
` : "", `
Stage trace
`, stage("Adapter", e.stages?.adapter), stage("Title", e.stages?.title), stage("URL", e.stages?.url), `
URL: ${escapeHtml(r.tab?.url || "")}
`, `
Title: ${escapeHtml(r.tab?.title || "")}
`, ].filter(Boolean).join(""); } catch (err) { out.innerHTML = `error: ${escapeHtml(err.message || String(err))}`; } }); // ---------- radio chips selected styling ---------- function syncRadioChips() { const trashLbl = document.getElementById("deleteModeTrashLbl"); const permLbl = document.getElementById("deleteModePermLbl"); trashLbl.classList.toggle("selected", document.getElementById("deleteModeTrash").checked); permLbl.classList.toggle("selected", document.getElementById("deleteModePerm").checked); permLbl.classList.add("danger"); // permanent always carries the danger style; .selected.danger = red } document.getElementById("deleteModeTrash").addEventListener("change", syncRadioChips); document.getElementById("deleteModePerm").addEventListener("change", syncRadioChips); function syncOverlayPosChips() { for (const r of document.querySelectorAll('input[name="overlayPosition"]')) { r.parentElement.classList.toggle("selected", r.checked); } } for (const r of document.querySelectorAll('input[name="overlayPosition"]')) { r.addEventListener("change", () => { syncOverlayPosChips(); updateOverlayPreview(); }); } // ---------- live overlay preview ---------- function hexToRgba(hex, a) { const m = String(hex).match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i); if (!m) return `rgba(110,193,255,${a})`; return `rgba(${parseInt(m[1], 16)},${parseInt(m[2], 16)},${parseInt(m[3], 16)},${a})`; } function updateOverlayPreview() { const preview = document.getElementById("overlay-preview"); const stage = document.getElementById("overlay-preview-stage"); const posLabel = document.getElementById("overlay-preview-pos"); if (!preview) return; const pos = (document.querySelector('input[name="overlayPosition"]:checked') || {}).value || "top-right"; const glow = document.getElementById("overlayGlow").checked; const color = document.getElementById("overlayGlowColor").value || "#6ec1ff"; const blur = parseInt(document.getElementById("overlayGlowBlur").value, 10) || 0; const spread = parseInt(document.getElementById("overlayGlowSpread").value, 10) || 0; const opacity = parseFloat(document.getElementById("overlayGlowOpacity").value) || 0.35; const dur = Math.max(1, parseInt(document.getElementById("overlayDuration").value, 10) || 5); // Stage alignment hints at chosen position stage.style.alignItems = pos.startsWith("top") ? "flex-start" : "flex-end"; stage.style.justifyContent = pos.endsWith("left") ? "flex-start" : "flex-end"; posLabel.textContent = `— ${pos} · ${dur}s · ${glow ? "glow on" : "no glow"}`; // Reflect slider values document.getElementById("overlayGlowBlurVal").textContent = `${blur} px`; document.getElementById("overlayGlowSpreadVal").textContent = `${spread} px`; document.getElementById("overlayGlowOpacityVal").textContent = opacity.toFixed(2); preview.style.boxShadow = glow ? `0 6px 20px rgba(0,0,0,0.55), 0 0 ${blur}px ${spread}px ${hexToRgba(color, opacity)}` : "0 6px 20px rgba(0,0,0,0.55)"; // Restart progress bar animation with current duration const bar = document.getElementById("overlay-preview-bar"); bar.style.animation = "none"; // force reflow void bar.offsetWidth; bar.style.animation = `rxshrink-preview ${dur}s linear infinite`; } // Inject keyframes once (function injectPreviewKeyframes() { const s = document.createElement("style"); s.textContent = "@keyframes rxshrink-preview { from { transform: scaleX(1); } to { transform: scaleX(0); } }"; document.head.appendChild(s); })(); document.getElementById("overlayGlow").addEventListener("change", updateOverlayPreview); document.getElementById("overlayGlowColor").addEventListener("input", updateOverlayPreview); document.getElementById("overlayDuration").addEventListener("input", updateOverlayPreview); document.getElementById("overlayGlowBlur").addEventListener("input", updateOverlayPreview); document.getElementById("overlayGlowSpread").addEventListener("input", updateOverlayPreview); document.getElementById("overlayGlowOpacity").addEventListener("input", updateOverlayPreview); document.getElementById("overlay-preview-replay").addEventListener("click", updateOverlayPreview); // ---------- no-match overlay ---------- function syncNoMatchPosChips() { for (const r of document.querySelectorAll('input[name="noMatchPosition"]')) { r.parentElement.classList.toggle("selected", r.checked); } } function updateMutualExclusion() { // Disable the radio in noMatchPosition that matches the chosen overlayPosition. const matchPos = (document.querySelector('input[name="overlayPosition"]:checked') || {}).value; for (const r of document.querySelectorAll('input[name="noMatchPosition"]')) { const same = r.value === matchPos; r.disabled = same; r.parentElement.style.opacity = same ? "0.4" : ""; r.parentElement.style.pointerEvents = same ? "none" : ""; if (same && r.checked) { // Auto-switch to first non-disabled r.checked = false; const fallback = Array.from(document.querySelectorAll('input[name="noMatchPosition"]')) .find((x) => x.value !== matchPos); if (fallback) fallback.checked = true; syncNoMatchPosChips(); updateNoMatchPreview(); } } // Same in reverse for overlayPosition (disable noMatch's selected one) const nmPos = (document.querySelector('input[name="noMatchPosition"]:checked') || {}).value; for (const r of document.querySelectorAll('input[name="overlayPosition"]')) { const same = r.value === nmPos && document.getElementById("noMatchOverlay").checked; r.disabled = same; r.parentElement.style.opacity = same ? "0.4" : ""; r.parentElement.style.pointerEvents = same ? "none" : ""; } } function updateNoMatchPreview() { const preview = document.getElementById("no-match-preview"); const stage = document.getElementById("no-match-preview-stage"); const posLabel = document.getElementById("no-match-preview-pos"); if (!preview) return; const pos = (document.querySelector('input[name="noMatchPosition"]:checked') || {}).value || "top-right"; const glow = document.getElementById("noMatchGlow").checked; const color = document.getElementById("noMatchGlowColor").value || "#ff6666"; const blur = parseInt(document.getElementById("noMatchGlowBlur").value, 10) || 0; const spread = parseInt(document.getElementById("noMatchGlowSpread").value, 10) || 0; const opacity = parseFloat(document.getElementById("noMatchGlowOpacity").value) || 0.35; const dur = Math.max(1, parseInt(document.getElementById("noMatchDuration").value, 10) || 5); stage.style.alignItems = pos.startsWith("top") ? "flex-start" : "flex-end"; stage.style.justifyContent = pos.endsWith("left") ? "flex-start" : "flex-end"; posLabel.textContent = `— ${pos} · ${dur}s · ${glow ? "glow on" : "no glow"}`; document.getElementById("noMatchGlowBlurVal").textContent = `${blur} px`; document.getElementById("noMatchGlowSpreadVal").textContent = `${spread} px`; document.getElementById("noMatchGlowOpacityVal").textContent = opacity.toFixed(2); preview.style.boxShadow = glow ? `0 6px 20px rgba(0,0,0,0.55), 0 0 ${blur}px ${spread}px ${hexToRgba(color, opacity)}` : "0 6px 20px rgba(0,0,0,0.55)"; const bar = document.getElementById("no-match-preview-bar"); bar.style.animation = "none"; void bar.offsetWidth; bar.style.animation = `rxshrink-preview ${dur}s linear infinite`; } for (const r of document.querySelectorAll('input[name="noMatchPosition"]')) { r.addEventListener("change", () => { syncNoMatchPosChips(); updateNoMatchPreview(); updateMutualExclusion(); }); } for (const id of ["noMatchOverlay", "noMatchGlow"]) { document.getElementById(id).addEventListener("change", () => { updateNoMatchPreview(); updateMutualExclusion(); }); } for (const id of ["noMatchGlowColor", "noMatchDuration", "noMatchGlowBlur", "noMatchGlowSpread", "noMatchGlowOpacity"]) { document.getElementById(id).addEventListener("input", updateNoMatchPreview); } document.getElementById("no-match-preview-replay").addEventListener("click", updateNoMatchPreview); // When match position changes, re-evaluate mutual exclusion for (const r of document.querySelectorAll('input[name="overlayPosition"]')) { r.addEventListener("change", updateMutualExclusion); } document.getElementById("no-match-reset").addEventListener("click", () => { document.getElementById("noMatchOverlay").checked = false; for (const r of document.querySelectorAll('input[name="noMatchPosition"]')) { r.checked = r.value === "top-right"; } syncNoMatchPosChips(); document.getElementById("noMatchDuration").value = 5; document.getElementById("noMatchGlow").checked = false; document.getElementById("noMatchGlowColor").value = "#ff6666"; document.getElementById("noMatchGlowBlur").value = 10; document.getElementById("noMatchGlowSpread").value = 0; document.getElementById("noMatchGlowOpacity").value = 0.35; updateNoMatchPreview(); updateMutualExclusion(); updateSectionSummaries(); }); document.getElementById("overlay-reset").addEventListener("click", () => { // Reset all overlay-related form fields to defaults. document.getElementById("showOverlay").checked = true; for (const r of document.querySelectorAll('input[name="overlayPosition"]')) { r.checked = r.value === "top-right"; } syncOverlayPosChips(); document.getElementById("overlayDuration").value = 5; document.getElementById("overlayGlow").checked = false; document.getElementById("overlayGlowColor").value = "#6ec1ff"; document.getElementById("overlayGlowBlur").value = 10; document.getElementById("overlayGlowSpread").value = 0; document.getElementById("overlayGlowOpacity").value = 0.35; updateOverlayPreview(); updateSectionSummaries(); }); // ---------- diagnostics ---------- // Extension ID display + copy button (added when Transfer Assistant was deleted). // Diagnostics is the canonical home for "what's my extension ID?" info now. (() => { const idEl = document.getElementById("diag-extension-id"); const copyBtn = document.getElementById("diag-copy-extension-id"); if (idEl) idEl.textContent = chrome.runtime.id; if (copyBtn) { copyBtn.addEventListener("click", async () => { try { await navigator.clipboard.writeText(chrome.runtime.id); copyBtn.textContent = "Copied"; setTimeout(() => { copyBtn.textContent = "Copy ID"; }, 1200); } catch (_) { copyBtn.textContent = "Copy failed"; setTimeout(() => { copyBtn.textContent = "Copy ID"; }, 1200); } }); } })(); document.getElementById("run-diag").addEventListener("click", (event) => keepActionViewport(event.currentTarget, runDiagnostics) ); document.getElementById("host-status-run").addEventListener("click", (event) => keepActionViewport(event.currentTarget, runHostStatus) ); document.getElementById("host-repair-run").addEventListener("click", (event) => keepActionViewport(event.currentTarget, runHostRepair) ); document.getElementById("host-verify-run").addEventListener("click", (event) => keepActionViewport(event.currentTarget, runHostStatus) ); document.getElementById("run-all-diag").addEventListener("click", (event) => keepActionViewport(event.currentTarget, async () => { clearNativeRepairCard(); const runtime = await runDiagnostics(); if (runtime && runtime.nativeBlocked) { renderBlockedByNativeIssue(document.getElementById("host-status-results"), "Host registration"); return; } await runHostStatus(); }) ); function renderDiagRows(out, checks, emptyLabel) { out.innerHTML = ""; if (!checks || checks.length === 0) { out.innerHTML = `
!${escapeHtml(emptyLabel)}no checks returned
`; return; } const counts = checks.reduce((acc, c) => { const status = c.status || "warn"; acc[status] = (acc[status] || 0) + 1; return acc; }, {}); const summary = document.createElement("div"); summary.className = "diag-row " + ((counts.fail || 0) ? "fail" : (counts.warn || 0) ? "warn" : "ok"); summary.innerHTML = `#summary${checks.length} checks · ok ${counts.ok || 0} · info ${counts.info || 0} · warn ${counts.warn || 0} · fail ${counts.fail || 0}`; out.appendChild(summary); for (const c of checks) { const row = document.createElement("div"); row.className = "diag-row " + (c.status || "warn"); const status = c.status || "warn"; const icon = status === "ok" ? "✓" : status === "info" ? "i" : status === "warn" ? "!" : "✗"; row.innerHTML = `${icon}${escapeHtml(c.name)}${formatDiagDetail(c.detail || "")}`; out.appendChild(row); } } function formatDiagDetail(detail) { const text = String(detail || ""); if (!text) return ""; const shouldCollapse = text.length > 120 || text.includes("\n") || (text.match(/[;|]/g) || []).length > 2; if (!shouldCollapse) return escapeHtml(text); const first = text.split(/\r?\n/)[0].slice(0, 110); return `
${escapeHtml(first)}${text.length > first.length ? "…" : ""}
${escapeHtml(text)}
`; } async function runDiagnostics() { const out = document.getElementById("diag-results"); clearNativeRepairCard(); out.innerHTML = '
running…waiting for native host
'; try { const r = await chrome.runtime.sendMessage({ type: "diagnostics" }); if (!r || !r.ok) { await renderNativeMessagingFailure(r); renderBlockedByNativeIssue(out, "Runtime diagnostics"); return { nativeBlocked: true }; } renderDiagRows(out, r.checks || [], "runtime"); return { ok: true }; } catch (err) { out.innerHTML = `
runtime${escapeHtml(err.message || String(err))}
`; return { ok: false }; } } async function runHostStatus() { const out = document.getElementById("host-status-results"); clearNativeRepairCard(); out.innerHTML = '
checking…reading manifest and registry state
'; try { const r = await chrome.runtime.sendMessage({ type: "host-status" }); if (!r || !r.ok) { await renderNativeMessagingFailure(r); renderBlockedByNativeIssue(out, "Native host checks"); return { nativeBlocked: true }; } renderDiagRows(out, r.checks || [], "host status"); return { ok: true }; } catch (err) { out.innerHTML = `
host status${escapeHtml(err.message || String(err))}
`; return { ok: false }; } } async function runHostRepair() { const out = document.getElementById("host-status-results"); clearNativeRepairCard(); out.innerHTML = '
repairing…updating reachable native host manifest and user registration
'; try { const r = await chrome.runtime.sendMessage({ type: "repair-host" }); if (!r || !r.ok) { if (r?.error_kind) { await renderNativeMessagingFailure(r); renderBlockedByNativeIssue(out, "Registration repair"); } else { out.innerHTML = `
Registration repair${escapeHtml(r?.error || "repair failed")}
`; } return { ok: false }; } const checks = r.verification?.checks || []; renderDiagRows(out, checks, "repair verification"); renderCompletedNativeRepair(r); return { ok: true }; } catch (err) { out.innerHTML = `
Registration repair${escapeHtml(err.message || String(err))}
`; return { ok: false }; } } function clearNativeRepairCard() { const card = document.getElementById("native-repair-card"); const out = document.getElementById("native-repair-results"); const title = document.getElementById("native-repair-title"); if (card) card.style.display = "none"; if (out) out.innerHTML = ""; if (title) title.textContent = "Native host setup"; } function renderCompletedNativeRepair(response) { const card = document.getElementById("native-repair-card"); const out = document.getElementById("native-repair-results"); if (!card || !out) return; card.style.display = ""; const title = document.getElementById("native-repair-title"); if (title) title.textContent = "Registration repair completed"; const regs = (response.registrations || []).filter((x) => x.status === "ok").length; out.innerHTML = `
Repair applied${escapeHtml(response.message || "native host registration repaired")}
iManifest${escapeHtml(response.manifest_path || "")}
iUser registry${escapeHtml(`${regs} HKCU registration entr${regs === 1 ? "y" : "ies"} updated`)}
!Restart requiredFully close Brave, reopen it, reload the extension, then click Verify Registration. If Brave still blocks the host, run the registration steps shown by Diagnostics.
`; } function renderBlockedByNativeIssue(out, title) { out.innerHTML = `
i${escapeHtml(title)}Blocked until this PC registers the native host for the current extension ID. Use the setup card above.
`; } async function getPackagedHostPaths() { try { const resp = await fetch(chrome.runtime.getURL("host/com.rcjav.host.json")); if (!resp.ok) return {}; const manifest = await resp.json(); const bat = manifest.path || ""; const hostDir = bat.replace(/[\\/][^\\/]+$/, ""); return { hostBat: bat, hostDir, registerBat: hostDir ? hostDir + "\\register-host.bat" : "", installPs1: hostDir ? hostDir + "\\install-host.ps1" : "", }; } catch { return {}; } } async function renderNativeMessagingFailure(response) { const card = document.getElementById("native-repair-card"); const out = document.getElementById("native-repair-results"); if (!card || !out) return; card.style.display = ""; const title = document.getElementById("native-repair-title"); if (title) title.textContent = "Register host on this PC"; const error = response?.error || "no response"; const kind = response?.error_kind || (/forbidden/i.test(error) ? "forbidden" : "unknown"); const extensionId = response?.extension_id || chrome.runtime.id; const paths = await getPackagedHostPaths(); const installCommand = paths.installPs1 ? `pwsh -ExecutionPolicy Bypass -File "${paths.installPs1}" -ExtensionId ${extensionId}` : `pwsh -ExecutionPolicy Bypass -File ".\\host\\install-host.ps1" -ExtensionId ${extensionId}`; const registerCommand = paths.registerBat ? `"${paths.registerBat}"` : ".\\host\\register-host.bat"; let cause = "This extension cannot launch the native messaging host yet."; let fix = "Register the host for this extension ID, fully restart Brave, then verify registration."; if (kind === "forbidden") { cause = "Brave found the native host, but this extension ID is not allowed to launch it on this PC."; fix = "This usually happens after loading the extension on another PC or under a different extension ID."; } else if (kind === "not_found") { cause = "Brave could not find a registered native messaging host for com.rcjav.host on this PC."; fix = "Run the registration script from the extension host folder."; } else if (kind === "disconnected") { cause = "The native host started and then disconnected or crashed."; fix = "After registration is fixed, run Runtime diagnostics again to check Python, rc-jav, and rclone."; } else if (kind === "timeout") { cause = "The native host did not respond before the timeout."; fix = "Restart Brave and check whether a scan or rclone command is stuck."; } out.innerHTML = `
!Setup requiredNative host registration must be fixed before cache, runtime, and host checks can run.
!Likely cause${escapeHtml(cause)}
iHost message${escapeHtml(error)}
Fix on this PC${escapeHtml(fix)}
iExtension ID${escapeHtml(extensionId)}
1Run register-host
${escapeHtml(registerCommand)}
${escapeHtml(`Run ${registerCommand}\nWhen it asks for the extension ID, enter:\n${extensionId}\n\nPowerShell alternative:\n${installCommand}`)}
2Restart BraveClose every Brave window/process, reopen Brave, then reload the extension.
3Verify
`; for (const btn of out.querySelectorAll("button[data-copy]")) { btn.addEventListener("click", async () => { await navigator.clipboard.writeText(btn.dataset.copy || ""); btn.textContent = "Copied"; setTimeout(() => { btn.textContent = btn.dataset.copyLabel || "Copy"; }, 1200); }); } for (const btn of out.querySelectorAll("button[data-verify-registration]")) { btn.addEventListener("click", runHostStatus); } } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } // ---------- profiles ---------- let _knownRemotes = []; // ["cq:", "gdrive:", ...] from rclone listremotes let _cfgDefaults = { source: [], target: [] }; let _remotesLoaded = false; async function fetchRemotes() { const status = document.getElementById("profiles-status"); if (_remotesLoaded) return; _remotesLoaded = true; if (status) status.textContent = "loading remotes..."; try { const r = await chrome.runtime.sendMessage({ type: "list-remotes" }); if (r && r.ok) { _knownRemotes = r.remotes || []; _cfgDefaults = { source: r.default_source || [], target: r.default_target || [] }; if (status) status.textContent = `${_knownRemotes.length} remote(s) loaded`; // Re-render to populate selects now that we have data const profiles = readProfiles(); renderProfiles(profiles); updateSectionSummaries(); } } catch (e) { _remotesLoaded = false; if (status) status.textContent = "failed to load remotes"; } } document.querySelector('.side .item[data-pane="profiles"]').addEventListener("click", fetchRemotes); document.getElementById("load-remotes").addEventListener("click", () => { _remotesLoaded = false; fetchRemotes(); }); /** * Build a remote picker widget. * Shows: a