Files
ext-rclone-jav/options.js
T
admin d0a2def788 Step 6c: extract Diagnostics + Profiles + Rules Editors from options.js
Final options.js split. Three new files:

  options-diagnostics.js     245 lines
  options-profiles.js        265 lines
  options-rules-editors.js   328 lines  (adapters + ID normalizers
                                          + custom part detectors)

options.js: 1852 → 1014 lines (838 extracted, ~45% reduction).

Script-tag order in options.html now (load order matters for
top-level let bindings shared across files, e.g. _configuredScanRoots):

  options-cache.js
  options-dupe-review.js
  options-library-issues.js
  options-diagnostics.js
  options-profiles.js
  options-rules-editors.js
  options.js  (entry: IIFE bottom, escapeHtml, overlay previews,
               element picker, paths)

The picker, overlay-preview, and no-match overlay code stays in
options.js — those are tightly intertwined with multiple settings
panes and not worth further splitting today.

node --check passes on each file individually and on the concatenated
load-order stream. Line count of concat (3144) matches the pre-split
sum exactly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 11:17:55 +02:00

1015 lines
47 KiB
JavaScript

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: <code>${escapeHtml(path)}</code>` : "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 = `
<div class="modal-help">Review this import before it replaces the current extension settings.</div>
<div class="diag-row warn"><span class="icon">!</span><span class="name">Overwrite</span><span class="detail">Current settings will be replaced by ${escapeHtml(Object.keys(sanitized).length)} imported value(s).</span></div>
<div class="diag-row info"><span class="icon">i</span><span class="name">Profiles</span><span class="detail">${escapeHtml(Array.isArray(sanitized.profiles) ? `${sanitized.profiles.length} profile(s) in this import` : "No profile list in this import")}</span></div>
<div class="diag-row ${sanitized.enableDelete ? "warn" : "info"}"><span class="icon">${sanitized.enableDelete ? "!" : "i"}</span><span class="name">Deletion</span><span class="detail">${escapeHtml(sanitized.enableDelete ? `Enabled in imported settings (${sanitized.deleteMode || "trash"} mode)` : "Not enabled by this import")}</span></div>
${dropped.length ? `<div class="diag-row warn"><span class="icon">!</span><span class="name">Ignored keys</span><span class="detail">${escapeHtml(`${dropped.length}: ${dropped.slice(0, 8).join(", ")}${dropped.length > 8 ? "..." : ""}`)}</span></div>` : `<div class="diag-row ok"><span class="icon">✓</span><span class="name">Schema</span><span class="detail">All imported keys are recognized.</span></div>`}
`;
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";
}
});
// ---------- 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 = `<span style="color:#777;">no recent activity yet</span>`;
return;
}
const visible = recentActivityEntries.filter(activityMatchesFilter).slice(0, 20);
if (!visible.length) {
out.innerHTML = `<span style="color:#777;">no recent activity for this filter</span>`;
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 ? `<div style="color:#888;margin-left:12px;">${escapeHtml(e.reason)}</div>` : "";
return `<div class="activity-entry">
<div class="activity-head">
<span class="activity-pill ${escapeHtml(outcome.cls)}">${escapeHtml(outcome.label)}</span>
<span>${escapeHtml(id)}</span>
<span class="activity-meta">${escapeHtml(e.trigger || "page")} · ${escapeHtml(fmtActivityWhen(e.ts))}${escapeHtml(mode)}${escapeHtml(timing)}</span>
</div>
<div style="color:#777;margin-left:12px;">${escapeHtml(page)}</div>
${reason}
</div>`;
}).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 = `<span style="color:#faa;">error:</span> ${escapeHtml(r?.error || "no response")}`;
return;
}
renderActivity(r.entries || []);
} catch (err) {
out.innerHTML = `<span style="color:#faa;">error:</span> ${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 `<div class="diag-row fail"><span class="icon">x</span><span class="name">${escapeHtml(label)}</span><span class="detail">${escapeHtml(blocked)}</span></div>`;
}
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 `<div class="diag-row ${status}"><span class="icon">${hitCount ? "OK" : "!"}</span><span class="name">${escapeHtml(label)}</span><span class="detail">${escapeHtml(detail)}</span></div>`;
}
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 = `<span style="color:#ffa;">paste an ID or text that contains one</span>`;
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 = `<span style="color:#faa;">extract failed:</span> ${escapeHtml(extraction?.error || "no response")}`;
return;
}
const extracted = extraction.extracted || {};
const extractionRows = [
`<div class="diag-row ${extracted.id ? "ok" : "warn"}"><span class="icon">${extracted.id ? "OK" : "!"}</span><span class="name">Extracted ID</span><span class="detail">${escapeHtml(extracted.id || "none")}</span></div>`,
`<div class="diag-row info"><span class="icon">i</span><span class="name">Rule</span><span class="detail">${escapeHtml(extracted.source || "none")}${extracted.pattern ? ` · ${escapeHtml(extracted.pattern)}` : ""}${extracted.replacement ? ` -> ${escapeHtml(extracted.replacement)}` : ""}</span></div>`,
];
if (!extracted.id) {
out.innerHTML = extractionRows.join("") + `<div style="color:#777;margin-top:7px;">No lookup ran because the pasted sample did not extract an ID.</div>`;
return;
}
out.innerHTML = extractionRows.join("") + `<div style="color:#777;margin-top:7px;">Comparing LIVE and CACHE for ${escapeHtml(extracted.id)}...</div>`;
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,
`<div class="diag-row info"><span class="icon">i</span><span class="name">Profile</span><span class="detail">${escapeHtml(activeProfile)}</span></div>`,
benchLookupRow("LIVE", live),
benchLookupRow("CACHE", cached),
mismatch
? `<div class="diag-row warn"><span class="icon">!</span><span class="name">Mismatch</span><span class="detail">LIVE and CACHE returned different hit counts. Check cache coverage/freshness and rebuild after ID-rule or library changes.</span></div>`
: `<div class="diag-row ok"><span class="icon">OK</span><span class="name">Compare</span><span class="detail">LIVE and CACHE returned the same hit count.</span></div>`,
`<div style="color:#777;margin-top:7px;">Extraction uses the ID Rules currently shown on this page. Search lookups use saved host settings and the active profile.</div>`,
].join("");
} catch (err) {
out.innerHTML = `<span style="color:#faa;">test failed:</span> ${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 = "";
});
// ---------- 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 = `<span style="color:#faa;">error:</span> ${escapeHtml(r?.error || "no response")}`;
return;
}
const e = r.extracted || {};
const selected = e.selected || {};
const stage = (name, value) => `<div><span style="color:#777;">${escapeHtml(name)}:</span> <span style="color:${value?.id ? "#afa" : "#666"};">${escapeHtml(value?.id || "none")}</span>${value?.raw ? ` · ${escapeHtml(value.raw)}` : ""}</div>`;
out.innerHTML = [
`<div><span style="color:#777;">ID:</span> <span style="color:${e.id ? "#afa" : "#faa"};">${escapeHtml(e.id || "none")}</span></div>`,
`<div><span style="color:#777;">Source:</span> ${escapeHtml(e.source || "none")}</div>`,
selected.adapter ? `<div><span style="color:#777;">Adapter:</span> ${escapeHtml(selected.adapter)}</div>` : "",
selected.selector ? `<div><span style="color:#777;">Selector:</span> ${escapeHtml(selected.selector)}</div>` : "",
selected.raw ? `<div><span style="color:#777;">Selected raw:</span> ${escapeHtml(selected.raw)}</div>` : "",
`<div style="margin-top:5px;color:#888;">Stage trace</div>`,
stage("Adapter", e.stages?.adapter),
stage("Title", e.stages?.title),
stage("URL", e.stages?.url),
`<div><span style="color:#777;">URL:</span> ${escapeHtml(r.tab?.url || "")}</div>`,
`<div><span style="color:#777;">Title:</span> ${escapeHtml(r.tab?.title || "")}</div>`,
].filter(Boolean).join("");
} catch (err) {
out.innerHTML = `<span style="color:#faa;">error:</span> ${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();
});
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
// ---------- paths ----------
document.getElementById("clear-rcjav-path").addEventListener("click", () => {
document.getElementById("rcjavPath").value = "";
document.getElementById("path-check-output").textContent = "using host default";
updateSectionSummaries();
});
document.getElementById("check-rcjav-path").addEventListener("click", async () => {
const out = document.getElementById("path-check-output");
out.textContent = "checking...";
chrome.runtime.sendMessage({ type: "ping-host", rcjavPath: document.getElementById("rcjavPath").value.trim() }, (r) => {
if (chrome.runtime.lastError) {
out.textContent = chrome.runtime.lastError.message;
return;
}
if (!r || !r.ok) {
out.textContent = r?.error || "no response";
return;
}
out.textContent = r.rc_jav_exists ? `ok: ${r.rc_jav}` : `not found: ${r.rc_jav}`;
updateSectionSummaries();
});
});
(async () => {
const { optionsActivePane, pendingNativeSetupIssue } = await chrome.storage.local.get(["optionsActivePane", "pendingNativeSetupIssue"]);
activatePane(optionsActivePane || "triggers");
await load();
if (pendingNativeSetupIssue) {
activatePane("diagnostics");
await renderNativeMessagingFailure(pendingNativeSetupIssue);
await chrome.storage.local.remove("pendingNativeSetupIssue");
}
refreshActivity();
})();