Files
ext-rclone-jav/options.js
T
admin e4ee06b19f Step 6b: extract Library Issues from options.js
Continues the options.js split. New file:

  options-library-issues.js  453 lines

After this step:

  options-cache.js            161 lines
  options-dupe-review.js      616 lines
  options-library-issues.js   453 lines
  options.js                 1903 lines  (was 2356 after step 6)

Library Issues block was fully self-contained (lastLibraryIssues,
_libraryIssuesDirty, renderLibraryIssues, _closeLibraryIssues, and the
bottom IIFE wrapping _optScanTimer / _setOptScanningState /
_pollOptProgress for optimization-scan progress polling). No external
callers of its identifiers.

Reads _configuredScanRoots / _cacheSkippedByRemote and calls
rememberConfiguredScanRoots from options-cache.js by bare reference —
same cross-file binding pattern proven in step 6.

node --check passes on each file and on the concatenation of all four
files in load order. Concat = 3133 lines, matching pre-split total.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 21:21:58 +02:00

1904 lines
85 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = "";
});
// ---------- 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 = `<span style="color:#faa;">error:</span> ${escapeHtml(r?.error || "no response")}`;
return;
}
const rows = [
`<div><span style="color:#777;">Mode:</span> ${escapeHtml(r.search_mode || "?")} · <span style="color:#777;">Queries:</span> ${escapeHtml(r.query_count || 0)} · <span style="color:#777;">Hits:</span> ${escapeHtml(r.hits || 0)} · <span style="color:#777;">Host:</span> ${escapeHtml(r.timings?.host_rcjav_ms ?? "?")}ms</div>`,
];
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(`<div style="margin-top:7px;">
<span style="color:${hit ? "#afa" : "#ffa"};font-weight:600;">${hit ? "HIT" : "MISS"}</span>
<span>${escapeHtml(q.query || "?")}</span> · ${escapeHtml(q.hits || 0)} hit(s)
${sample ? `<div style="color:#777;margin-left:12px;">${escapeHtml(sample)}</div>` : `<div style="color:#777;margin-left:12px;">${escapeHtml(q.no_match_title || "No library hit")}</div>`}
</div>`);
}
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 = `<span style="color:#ffa;">paste at least one ID</span>`;
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 = "";
});
// ---------- 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 = `
<td><input type="text" class="host" placeholder="clearjav.com"></td>
<td><input type="text" class="selector" placeholder=".some-class"></td>
<td><button class="del" type="button">×</button></td>`;
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 = `
<td><input type="text" class="re" placeholder="\\b1pondo-?(\\d{4,})-?(\\d{2,})\\b"></td>
<td><input type="text" class="fmt" placeholder="1pondo-$1-$2"></td>
<td><button class="del" type="button">×</button></td>`;
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 = `<span class="warn">Enter a detector regex.</span> Capture group 1 should be the part token.`;
return;
}
let re;
try {
re = partDetectorRegex(pattern);
} catch (err) {
feedback.innerHTML = `<span class="fail">Invalid preview regex:</span> ${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 = `<span class="warn">No representative sample matched.</span> 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
? `<div class="warn">These representative matches are already covered by built-in detector${new Set(covered.map((entry) => entry.detector.pattern)).size === 1 ? "" : "s"}.</div>`
: "";
feedback.innerHTML = [
`<span class="${missingCapture ? "warn" : "ok"}">${missingCapture ? "Matched, but capture group 1 was missing for a sample." : `Matches ${matches.length} representative filename shape${matches.length === 1 ? "" : "s"}.`}</span>`,
coveredNote,
...matches.slice(0, 4).map((item) => `<div class="part-detector-match">${escapeHtml(item.filename)} -> part ${escapeHtml(item.part)} <span style="color:#777;">(${escapeHtml(item.stage)})</span></div>`),
matches.length > 4 ? `<div>and ${escapeHtml(matches.length - 4)} more representative match(es)</div>` : "",
].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 = `
<div class="part-detector-head">
<input type="text" class="part-detector-pattern" placeholder="_PART(\\d+)$"${builtin ? " readonly" : ""}>
${builtin ? `<span class="part-detector-kind">Built in</span>` : `<button type="button" title="Remove detector">x</button>`}
</div>
${note ? `<div class="muted" style="margin-top:5px;">${escapeHtml(note)}</div>` : ""}
<div class="part-detector-feedback"></div>
`;
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 = `<span style="color:#faa;">error:</span> ${escapeHtml(r?.error || "no response")}`;
return;
}
const e = r.extracted || {};
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;">Rule:</span> ${escapeHtml(e.source || "none")}</div>`,
e.pattern ? `<div><span style="color:#777;">Pattern:</span> ${escapeHtml(e.pattern)}</div>` : "",
e.replacement ? `<div><span style="color:#777;">Replacement:</span> ${escapeHtml(e.replacement)}</div>` : "",
e.raw ? `<div><span style="color:#777;">Raw:</span> ${escapeHtml(e.raw)}</div>` : "",
].filter(Boolean).join("");
} catch (err) {
out.innerHTML = `<span style="color:#faa;">error:</span> ${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 = `<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();
});
// ---------- 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 = `<div class="diag-row warn"><span class="icon">!</span><span class="name">${escapeHtml(emptyLabel)}</span><span class="detail">no checks returned</span></div>`;
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 = `<span class="icon">#</span><span class="name">summary</span><span class="detail">${checks.length} checks · ok ${counts.ok || 0} · info ${counts.info || 0} · warn ${counts.warn || 0} · fail ${counts.fail || 0}</span>`;
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 = `<span class="icon">${icon}</span><span class="name">${escapeHtml(c.name)}</span><span class="detail">${formatDiagDetail(c.detail || "")}</span>`;
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 `<details><summary>${escapeHtml(first)}${text.length > first.length ? "…" : ""}</summary><pre>${escapeHtml(text)}</pre></details>`;
}
async function runDiagnostics() {
const out = document.getElementById("diag-results");
clearNativeRepairCard();
out.innerHTML = '<div class="diag-row warn"><span class="icon">…</span><span class="name">running…</span><span class="detail">waiting for native host</span></div>';
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 = `<div class="diag-row fail"><span class="icon">✗</span><span class="name">runtime</span><span class="detail">${escapeHtml(err.message || String(err))}</span></div>`;
return { ok: false };
}
}
async function runHostStatus() {
const out = document.getElementById("host-status-results");
clearNativeRepairCard();
out.innerHTML = '<div class="diag-row warn"><span class="icon">…</span><span class="name">checking…</span><span class="detail">reading manifest and registry state</span></div>';
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 = `<div class="diag-row fail"><span class="icon">✗</span><span class="name">host status</span><span class="detail">${escapeHtml(err.message || String(err))}</span></div>`;
return { ok: false };
}
}
async function runHostRepair() {
const out = document.getElementById("host-status-results");
clearNativeRepairCard();
out.innerHTML = '<div class="diag-row warn"><span class="icon">…</span><span class="name">repairing…</span><span class="detail">updating reachable native host manifest and user registration</span></div>';
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 = `<div class="diag-row fail"><span class="icon">✗</span><span class="name">Registration repair</span><span class="detail">${escapeHtml(r?.error || "repair failed")}</span></div>`;
}
return { ok: false };
}
const checks = r.verification?.checks || [];
renderDiagRows(out, checks, "repair verification");
renderCompletedNativeRepair(r);
return { ok: true };
} catch (err) {
out.innerHTML = `<div class="diag-row fail"><span class="icon">✗</span><span class="name">Registration repair</span><span class="detail">${escapeHtml(err.message || String(err))}</span></div>`;
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 = `
<div class="diag-row ok"><span class="icon">✓</span><span class="name">Repair applied</span><span class="detail">${escapeHtml(response.message || "native host registration repaired")}</span></div>
<div class="diag-row info"><span class="icon">i</span><span class="name">Manifest</span><span class="detail">${escapeHtml(response.manifest_path || "")}</span></div>
<div class="diag-row info"><span class="icon">i</span><span class="name">User registry</span><span class="detail">${escapeHtml(`${regs} HKCU registration entr${regs === 1 ? "y" : "ies"} updated`)}</span></div>
<div class="diag-row warn"><span class="icon">!</span><span class="name">Restart required</span><span class="detail">Fully close Brave, reopen it, reload the extension, then click Verify Registration. If Brave still blocks the host, run the registration steps shown by Diagnostics.</span></div>
`;
}
function renderBlockedByNativeIssue(out, title) {
out.innerHTML = `<div class="diag-row info"><span class="icon">i</span><span class="name">${escapeHtml(title)}</span><span class="detail">Blocked until this PC registers the native host for the current extension ID. Use the setup card above.</span></div>`;
}
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 = `
<div class="diag-row warn"><span class="icon">!</span><span class="name">Setup required</span><span class="detail">Native host registration must be fixed before cache, runtime, and host checks can run.</span></div>
<div class="diag-row warn"><span class="icon">!</span><span class="name">Likely cause</span><span class="detail">${escapeHtml(cause)}</span></div>
<div class="diag-row info"><span class="icon">i</span><span class="name">Host message</span><span class="detail">${escapeHtml(error)}</span></div>
<div class="diag-row ok"><span class="icon">→</span><span class="name">Fix on this PC</span><span class="detail">${escapeHtml(fix)}</span></div>
<div class="diag-row info"><span class="icon">i</span><span class="name">Extension ID</span><span class="detail">${escapeHtml(extensionId)}</span></div>
<div class="diag-row info"><span class="icon">1</span><span class="name">Run register-host</span><span class="detail">
<details open><summary>${escapeHtml(registerCommand)}</summary><pre>${escapeHtml(`Run ${registerCommand}\nWhen it asks for the extension ID, enter:\n${extensionId}\n\nPowerShell alternative:\n${installCommand}`)}</pre></details>
<span class="diag-action"><button type="button" data-copy="${escapeHtml(registerCommand)}" data-copy-label="Copy Script Path">Copy Script Path</button><button type="button" data-copy="${escapeHtml(extensionId)}" data-copy-label="Copy Extension ID">Copy Extension ID</button><button type="button" data-copy="${escapeHtml(installCommand)}" data-copy-label="Copy PowerShell Alternative">Copy PowerShell Alternative</button></span>
</span></div>
<div class="diag-row info"><span class="icon">2</span><span class="name">Restart Brave</span><span class="detail">Close every Brave window/process, reopen Brave, then reload the extension.</span></div>
<div class="diag-row info"><span class="icon">3</span><span class="name">Verify</span><span class="detail"><span class="diag-action"><button type="button" data-verify-registration>Verify Registration</button></span></span></div>
`;
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 <select> dropdown of known remotes + an editable path suffix input +
* an Add button. Added remotes appear as chips below with × to remove.
* Falls back gracefully to a plain text input if no remotes loaded yet.
*/
function buildRemotePicker(container, values) {
container.innerHTML = "";
// --- selected list ---
const selectedList = document.createElement("div");
selectedList.className = "prof-selected-list";
selectedList.style.cssText = "margin-bottom:6px;";
container.appendChild(selectedList);
function addChip(path) {
const chip = document.createElement("div");
chip.className = "prof-chip";
chip.style.cssText = "display:flex;align-items:center;gap:6px;margin-bottom:4px;";
// Editable path so user can adjust subpath after picking
const inp = document.createElement("input");
inp.type = "text";
inp.value = path;
inp.className = "prof-chip-input";
inp.style.cssText = "flex:1;font-family:Consolas,monospace;font-size:12px;";
const rm = document.createElement("button");
rm.type = "button";
rm.textContent = "×";
rm.title = "Remove";
rm.style.cssText = "background:#511;border:1px solid #722;color:#faa;border-radius:3px;padding:0 7px;cursor:pointer;font-size:14px;line-height:1;";
rm.addEventListener("click", () => chip.remove());
chip.appendChild(inp);
chip.appendChild(rm);
selectedList.appendChild(chip);
}
// Pre-populate with existing values
for (const v of (values || [])) addChip(v);
// --- add row: select + optional subpath + Add button ---
const addRow = document.createElement("div");
addRow.style.cssText = "display:flex;gap:6px;align-items:center;flex-wrap:wrap;";
const sel = document.createElement("select");
sel.style.cssText = "background:#0d0d0d;color:#ddd;border:1px solid #2a2a2a;border-radius:4px;padding:5px 8px;font-family:Consolas,monospace;font-size:12px;min-width:130px;";
if (_knownRemotes.length) {
for (const r of _knownRemotes) {
const opt = document.createElement("option");
opt.value = r;
opt.textContent = r;
sel.appendChild(opt);
}
} else {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "(loading…)";
sel.appendChild(opt);
}
const subpathInp = document.createElement("input");
subpathInp.type = "text";
subpathInp.placeholder = "optional/subpath";
subpathInp.style.cssText = "flex:1;min-width:120px;font-family:Consolas,monospace;font-size:12px;";
subpathInp.title = "Append a subpath to narrow the remote, e.g. JAV/ClearJAV";
const addBtn = document.createElement("button");
addBtn.type = "button";
addBtn.textContent = "+ Add";
addBtn.style.cssText = "padding:5px 12px;font-size:12px;white-space:nowrap;";
addBtn.addEventListener("click", () => {
const base = sel.value.trim();
if (!base) return;
const sub = subpathInp.value.trim().replace(/^\//, "");
const full = sub ? base + sub : base;
addChip(full);
subpathInp.value = "";
});
// Also allow typing a fully custom path
const customInp = document.createElement("input");
customInp.type = "text";
customInp.placeholder = "or type full path (e.g. cq:JAV/ClearJAV)";
customInp.style.cssText = "flex:1;min-width:160px;font-family:Consolas,monospace;font-size:12px;margin-top:4px;";
const customBtn = document.createElement("button");
customBtn.type = "button";
customBtn.textContent = "+ Add";
customBtn.style.cssText = "padding:5px 12px;font-size:12px;margin-top:4px;white-space:nowrap;";
customBtn.addEventListener("click", () => {
const v = customInp.value.trim();
if (v) { addChip(v); customInp.value = ""; }
});
customInp.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); customBtn.click(); } });
addRow.appendChild(sel);
addRow.appendChild(subpathInp);
addRow.appendChild(addBtn);
container.appendChild(addRow);
const customRow = document.createElement("div");
customRow.style.cssText = "display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:4px;";
customRow.appendChild(customInp);
customRow.appendChild(customBtn);
container.appendChild(customRow);
}
function readRemoteGroup(container) {
return [...container.querySelectorAll(".prof-chip-input")]
.map(i => i.value.trim()).filter(Boolean);
}
function describeProfileRoots(label, values, defaults) {
const roots = (values || []).filter(Boolean);
if (roots.length) return `${label}: ${roots.join(", ")}`;
if ((defaults || []).length) return `${label}: config default ${defaults.join(", ")}`;
return `${label}: config.json default`;
}
function cloneProfile(profile) {
return {
name: (profile?.name || "").trim(),
source: [...(profile?.source || [])].filter(Boolean),
target: [...(profile?.target || [])].filter(Boolean),
};
}
function buildProfileRow(profile) {
const p = cloneProfile(profile);
const row = document.createElement("div");
row.className = "profile-card prof-row";
row._profile = p;
const detail = document.createElement("div");
detail.innerHTML = `
<div class="name">${escapeHtml(p.name)}</div>
<div class="roots">${escapeHtml(describeProfileRoots("Source", p.source, _cfgDefaults.source))}<br>${escapeHtml(describeProfileRoots("Target", p.target, _cfgDefaults.target))}</div>
`;
const actions = document.createElement("div");
actions.className = "actions";
const editBtn = document.createElement("button");
editBtn.type = "button";
editBtn.textContent = "Edit";
editBtn.addEventListener("click", () => openProfileModal(row));
const delBtn = document.createElement("button");
delBtn.type = "button";
delBtn.className = "danger";
delBtn.textContent = "Remove";
delBtn.addEventListener("click", () => {
row.remove();
updateSectionSummaries();
});
actions.appendChild(editBtn);
actions.appendChild(delBtn);
row.appendChild(detail);
row.appendChild(actions);
return row;
}
function renderProfiles(profiles) {
const list = document.getElementById("profiles-list");
list.innerHTML = "";
if (!profiles.length) {
const msg = document.createElement("div");
msg.style.cssText = "color:#666;font-size:12px;font-style:italic;margin-bottom:8px;";
msg.textContent = "No profiles defined. Searches use rc-jav's config.json defaults.";
list.appendChild(msg);
return;
}
for (const p of profiles) list.appendChild(buildProfileRow(p));
}
function readProfiles() {
return [...document.querySelectorAll("#profiles-list .prof-row")]
.map((row) => cloneProfile(row._profile))
.filter((profile) => profile.name);
}
let editingProfileRow = null;
function setProfileModalDefaultsNote() {
const src = _cfgDefaults.source.length ? _cfgDefaults.source.join(", ") : "config.json default_source";
const tgt = _cfgDefaults.target.length ? _cfgDefaults.target.join(", ") : "config.json default_target";
document.getElementById("profile-modal-status").textContent = `Empty remote lists inherit source ${src} and target ${tgt}.`;
}
async function openProfileModal(row = null) {
editingProfileRow = row;
await fetchRemotes();
const profile = cloneProfile(row?._profile);
document.getElementById("profile-modal-title").textContent = row ? "Edit Library Profile" : "Add Library Profile";
document.getElementById("profile-modal-name").value = profile.name;
buildRemotePicker(document.getElementById("profile-modal-source"), profile.source);
buildRemotePicker(document.getElementById("profile-modal-target"), profile.target);
setProfileModalDefaultsNote();
openModal("profile-modal");
document.getElementById("profile-modal-name").focus();
}
function closeProfileModal() {
editingProfileRow = null;
closeModal("profile-modal");
}
document.getElementById("add-profile").addEventListener("click", () => openProfileModal());
document.getElementById("profile-modal-save").addEventListener("click", () => {
const name = document.getElementById("profile-modal-name").value.trim();
const status = document.getElementById("profile-modal-status");
if (!name) {
status.textContent = "Profile name is required.";
return;
}
const profile = {
name,
source: readRemoteGroup(document.getElementById("profile-modal-source")),
target: readRemoteGroup(document.getElementById("profile-modal-target")),
};
const list = document.getElementById("profiles-list");
if (editingProfileRow) {
editingProfileRow.replaceWith(buildProfileRow(profile));
} else {
list.querySelector("div[style*='italic']")?.remove();
list.appendChild(buildProfileRow(profile));
}
closeProfileModal();
updateSectionSummaries();
});
for (const id of ["profile-modal-close", "profile-modal-cancel"]) {
document.getElementById(id).addEventListener("click", closeProfileModal);
}
document.getElementById("profile-modal").addEventListener("click", (event) => {
if (event.target.id === "profile-modal") closeProfileModal();
});
// ---------- 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();
})();