diff --git a/options-diagnostics.js b/options-diagnostics.js
new file mode 100644
index 0000000..8d523b5
--- /dev/null
+++ b/options-diagnostics.js
@@ -0,0 +1,245 @@
+// ---------- diagnostics ----------
+
+// Extension ID display + copy button (added when Transfer Assistant was deleted).
+// Diagnostics is the canonical home for "what's my extension ID?" info now.
+(() => {
+ const idEl = document.getElementById("diag-extension-id");
+ const copyBtn = document.getElementById("diag-copy-extension-id");
+ if (idEl) idEl.textContent = chrome.runtime.id;
+ if (copyBtn) {
+ copyBtn.addEventListener("click", async () => {
+ try {
+ await navigator.clipboard.writeText(chrome.runtime.id);
+ copyBtn.textContent = "Copied";
+ setTimeout(() => { copyBtn.textContent = "Copy ID"; }, 1200);
+ } catch (_) {
+ copyBtn.textContent = "Copy failed";
+ setTimeout(() => { copyBtn.textContent = "Copy ID"; }, 1200);
+ }
+ });
+ }
+})();
+
+document.getElementById("run-diag").addEventListener("click", (event) =>
+ keepActionViewport(event.currentTarget, runDiagnostics)
+);
+document.getElementById("host-status-run").addEventListener("click", (event) =>
+ keepActionViewport(event.currentTarget, runHostStatus)
+);
+document.getElementById("host-repair-run").addEventListener("click", (event) =>
+ keepActionViewport(event.currentTarget, runHostRepair)
+);
+document.getElementById("host-verify-run").addEventListener("click", (event) =>
+ keepActionViewport(event.currentTarget, runHostStatus)
+);
+document.getElementById("run-all-diag").addEventListener("click", (event) =>
+ keepActionViewport(event.currentTarget, async () => {
+ clearNativeRepairCard();
+ const runtime = await runDiagnostics();
+ if (runtime && runtime.nativeBlocked) {
+ renderBlockedByNativeIssue(document.getElementById("host-status-results"), "Host registration");
+ return;
+ }
+ await runHostStatus();
+ })
+);
+
+function renderDiagRows(out, checks, emptyLabel) {
+ out.innerHTML = "";
+ if (!checks || checks.length === 0) {
+ out.innerHTML = `
! ${escapeHtml(emptyLabel)} no checks returned
`;
+ return;
+ }
+ const counts = checks.reduce((acc, c) => {
+ const status = c.status || "warn";
+ acc[status] = (acc[status] || 0) + 1;
+ return acc;
+ }, {});
+ const summary = document.createElement("div");
+ summary.className = "diag-row " + ((counts.fail || 0) ? "fail" : (counts.warn || 0) ? "warn" : "ok");
+ summary.innerHTML = `# summary ${checks.length} checks · ok ${counts.ok || 0} · info ${counts.info || 0} · warn ${counts.warn || 0} · fail ${counts.fail || 0} `;
+ out.appendChild(summary);
+ for (const c of checks) {
+ const row = document.createElement("div");
+ row.className = "diag-row " + (c.status || "warn");
+ const status = c.status || "warn";
+ const icon = status === "ok" ? "✓" : status === "info" ? "i" : status === "warn" ? "!" : "✗";
+ row.innerHTML = `${icon} ${escapeHtml(c.name)} ${formatDiagDetail(c.detail || "")} `;
+ out.appendChild(row);
+ }
+}
+
+function formatDiagDetail(detail) {
+ const text = String(detail || "");
+ if (!text) return "";
+ const shouldCollapse = text.length > 120 || text.includes("\n") || (text.match(/[;|]/g) || []).length > 2;
+ if (!shouldCollapse) return escapeHtml(text);
+ const first = text.split(/\r?\n/)[0].slice(0, 110);
+ return `${escapeHtml(first)}${text.length > first.length ? "…" : ""} ${escapeHtml(text)} `;
+}
+
+async function runDiagnostics() {
+ const out = document.getElementById("diag-results");
+ clearNativeRepairCard();
+ out.innerHTML = '… running… waiting for native host
';
+ try {
+ const r = await chrome.runtime.sendMessage({ type: "diagnostics" });
+ if (!r || !r.ok) {
+ await renderNativeMessagingFailure(r);
+ renderBlockedByNativeIssue(out, "Runtime diagnostics");
+ return { nativeBlocked: true };
+ }
+ renderDiagRows(out, r.checks || [], "runtime");
+ return { ok: true };
+ } catch (err) {
+ out.innerHTML = `✗ runtime ${escapeHtml(err.message || String(err))}
`;
+ return { ok: false };
+ }
+}
+
+async function runHostStatus() {
+ const out = document.getElementById("host-status-results");
+ clearNativeRepairCard();
+ out.innerHTML = '… checking… reading manifest and registry state
';
+ try {
+ const r = await chrome.runtime.sendMessage({ type: "host-status" });
+ if (!r || !r.ok) {
+ await renderNativeMessagingFailure(r);
+ renderBlockedByNativeIssue(out, "Native host checks");
+ return { nativeBlocked: true };
+ }
+ renderDiagRows(out, r.checks || [], "host status");
+ return { ok: true };
+ } catch (err) {
+ out.innerHTML = `✗ host status ${escapeHtml(err.message || String(err))}
`;
+ return { ok: false };
+ }
+}
+
+async function runHostRepair() {
+ const out = document.getElementById("host-status-results");
+ clearNativeRepairCard();
+ out.innerHTML = '… repairing… updating reachable native host manifest and user registration
';
+ try {
+ const r = await chrome.runtime.sendMessage({ type: "repair-host" });
+ if (!r || !r.ok) {
+ if (r?.error_kind) {
+ await renderNativeMessagingFailure(r);
+ renderBlockedByNativeIssue(out, "Registration repair");
+ } else {
+ out.innerHTML = `✗ Registration repair ${escapeHtml(r?.error || "repair failed")}
`;
+ }
+ return { ok: false };
+ }
+ const checks = r.verification?.checks || [];
+ renderDiagRows(out, checks, "repair verification");
+ renderCompletedNativeRepair(r);
+ return { ok: true };
+ } catch (err) {
+ out.innerHTML = `✗ Registration repair ${escapeHtml(err.message || String(err))}
`;
+ return { ok: false };
+ }
+}
+
+function clearNativeRepairCard() {
+ const card = document.getElementById("native-repair-card");
+ const out = document.getElementById("native-repair-results");
+ const title = document.getElementById("native-repair-title");
+ if (card) card.style.display = "none";
+ if (out) out.innerHTML = "";
+ if (title) title.textContent = "Native host setup";
+}
+
+function renderCompletedNativeRepair(response) {
+ const card = document.getElementById("native-repair-card");
+ const out = document.getElementById("native-repair-results");
+ if (!card || !out) return;
+ card.style.display = "";
+ const title = document.getElementById("native-repair-title");
+ if (title) title.textContent = "Registration repair completed";
+ const regs = (response.registrations || []).filter((x) => x.status === "ok").length;
+ out.innerHTML = `
+ ✓ Repair applied ${escapeHtml(response.message || "native host registration repaired")}
+ i Manifest ${escapeHtml(response.manifest_path || "")}
+ i User registry ${escapeHtml(`${regs} HKCU registration entr${regs === 1 ? "y" : "ies"} updated`)}
+ ! Restart required 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.
+ `;
+}
+
+function renderBlockedByNativeIssue(out, title) {
+ out.innerHTML = `i ${escapeHtml(title)} Blocked until this PC registers the native host for the current extension ID. Use the setup card above.
`;
+}
+
+async function getPackagedHostPaths() {
+ try {
+ const resp = await fetch(chrome.runtime.getURL("host/com.rcjav.host.json"));
+ if (!resp.ok) return {};
+ const manifest = await resp.json();
+ const bat = manifest.path || "";
+ const hostDir = bat.replace(/[\\/][^\\/]+$/, "");
+ return {
+ hostBat: bat,
+ hostDir,
+ registerBat: hostDir ? hostDir + "\\register-host.bat" : "",
+ installPs1: hostDir ? hostDir + "\\install-host.ps1" : "",
+ };
+ } catch {
+ return {};
+ }
+}
+
+async function renderNativeMessagingFailure(response) {
+ const card = document.getElementById("native-repair-card");
+ const out = document.getElementById("native-repair-results");
+ if (!card || !out) return;
+ card.style.display = "";
+ const title = document.getElementById("native-repair-title");
+ if (title) title.textContent = "Register host on this PC";
+ const error = response?.error || "no response";
+ const kind = response?.error_kind || (/forbidden/i.test(error) ? "forbidden" : "unknown");
+ const extensionId = response?.extension_id || chrome.runtime.id;
+ const paths = await getPackagedHostPaths();
+ const installCommand = paths.installPs1
+ ? `pwsh -ExecutionPolicy Bypass -File "${paths.installPs1}" -ExtensionId ${extensionId}`
+ : `pwsh -ExecutionPolicy Bypass -File ".\\host\\install-host.ps1" -ExtensionId ${extensionId}`;
+ const registerCommand = paths.registerBat ? `"${paths.registerBat}"` : ".\\host\\register-host.bat";
+ let cause = "This extension cannot launch the native messaging host yet.";
+ let fix = "Register the host for this extension ID, fully restart Brave, then verify registration.";
+ if (kind === "forbidden") {
+ cause = "Brave found the native host, but this extension ID is not allowed to launch it on this PC.";
+ fix = "This usually happens after loading the extension on another PC or under a different extension ID.";
+ } else if (kind === "not_found") {
+ cause = "Brave could not find a registered native messaging host for com.rcjav.host on this PC.";
+ fix = "Run the registration script from the extension host folder.";
+ } else if (kind === "disconnected") {
+ cause = "The native host started and then disconnected or crashed.";
+ fix = "After registration is fixed, run Runtime diagnostics again to check Python, rc-jav, and rclone.";
+ } else if (kind === "timeout") {
+ cause = "The native host did not respond before the timeout.";
+ fix = "Restart Brave and check whether a scan or rclone command is stuck.";
+ }
+ out.innerHTML = `
+ ! Setup required Native host registration must be fixed before cache, runtime, and host checks can run.
+ ! Likely cause ${escapeHtml(cause)}
+ i Host message ${escapeHtml(error)}
+ → Fix on this PC ${escapeHtml(fix)}
+ i Extension ID ${escapeHtml(extensionId)}
+ 1 Run register-host
+ ${escapeHtml(registerCommand)} ${escapeHtml(`Run ${registerCommand}\nWhen it asks for the extension ID, enter:\n${extensionId}\n\nPowerShell alternative:\n${installCommand}`)}
+ Copy Script Path Copy Extension ID Copy PowerShell Alternative
+
+ 2 Restart Brave Close every Brave window/process, reopen Brave, then reload the extension.
+ 3 Verify Verify Registration
+ `;
+ 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);
+ }
+}
+
diff --git a/options-profiles.js b/options-profiles.js
new file mode 100644
index 0000000..f11b65a
--- /dev/null
+++ b/options-profiles.js
@@ -0,0 +1,265 @@
+// ---------- 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 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 = `
+ ${escapeHtml(p.name)}
+ ${escapeHtml(describeProfileRoots("Source", p.source, _cfgDefaults.source))} ${escapeHtml(describeProfileRoots("Target", p.target, _cfgDefaults.target))}
+ `;
+ 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();
+});
+
diff --git a/options-rules-editors.js b/options-rules-editors.js
new file mode 100644
index 0000000..c310684
--- /dev/null
+++ b/options-rules-editors.js
@@ -0,0 +1,328 @@
+// ---------- adapters ----------
+
+function renderAdapters(list) {
+ const tbody = document.querySelector("#adapters tbody");
+ tbody.innerHTML = "";
+ for (const a of list) addAdapterRow(a.host || "", a.selector || "");
+ if (list.length === 0) addAdapterRow("", "");
+}
+
+function addAdapterRow(host, selector) {
+ const tbody = document.querySelector("#adapters tbody");
+ const tr = document.createElement("tr");
+ tr.innerHTML = `
+
+
+ × `;
+ tr.querySelector(".host").value = host;
+ tr.querySelector(".selector").value = selector;
+ tr.querySelector(".del").addEventListener("click", () => tr.remove());
+ tbody.appendChild(tr);
+}
+
+function readAdapters() {
+ const rows = document.querySelectorAll("#adapters tbody tr");
+ const out = [];
+ for (const tr of rows) {
+ const host = tr.querySelector(".host").value.trim();
+ const selector = tr.querySelector(".selector").value.trim();
+ if (host && selector) out.push({ host, selector });
+ }
+ return out;
+}
+
+document.getElementById("add-adapter").addEventListener("click", () => addAdapterRow("", ""));
+
+document.getElementById("validate-adapters").addEventListener("click", () => {
+ const status = document.getElementById("picker-status");
+ const rows = [...document.querySelectorAll("#adapters tbody tr")];
+ const seen = new Set();
+ const issues = [];
+ for (const tr of rows) {
+ const host = tr.querySelector(".host").value.trim();
+ const selector = tr.querySelector(".selector").value.trim();
+ tr.style.outline = "";
+ if (!host && !selector) continue;
+ if (!host || !selector) {
+ issues.push("rows need both host and selector");
+ tr.style.outline = "1px solid #775";
+ continue;
+ }
+ const key = host.toLowerCase();
+ if (seen.has(key)) {
+ issues.push(`duplicate host: ${host}`);
+ tr.style.outline = "1px solid #775";
+ }
+ seen.add(key);
+ try { document.querySelector(selector); } catch {
+ issues.push(`invalid CSS selector for ${host}`);
+ tr.style.outline = "1px solid #775";
+ }
+ }
+ status.textContent = issues.length ? [...new Set(issues)].join("; ") : `${readAdapters().length} adapter row(s) look valid`;
+ updateSectionSummaries();
+});
+
+// ---------- ID normalizers ----------
+
+function renderNormalizers(list) {
+ const tbody = document.querySelector("#normalizers tbody");
+ tbody.innerHTML = "";
+ for (const n of list) addNormalizerRow(n.re || "", n.fmt || "");
+ if (list.length === 0) addNormalizerRow("", "");
+}
+
+function addNormalizerRow(re, fmt) {
+ const tbody = document.querySelector("#normalizers tbody");
+ const tr = document.createElement("tr");
+ tr.innerHTML = `
+
+
+ × `;
+ tr.querySelector(".re").value = re;
+ tr.querySelector(".fmt").value = fmt;
+ tr.querySelector(".del").addEventListener("click", () => tr.remove());
+ tbody.appendChild(tr);
+}
+
+function readNormalizers() {
+ const rows = document.querySelectorAll("#normalizers tbody tr");
+ const out = [];
+ for (const tr of rows) {
+ const re = tr.querySelector(".re").value.trim();
+ const fmt = tr.querySelector(".fmt").value.trim();
+ if (re && fmt) out.push({ re, fmt });
+ }
+ return out;
+}
+
+document.getElementById("add-normalizer").addEventListener("click", () => addNormalizerRow("", ""));
+
+document.getElementById("validate-normalizers").addEventListener("click", () => {
+ const status = document.getElementById("normalizer-status");
+ const rows = [...document.querySelectorAll("#normalizers tbody tr")];
+ const issues = [];
+ for (const tr of rows) {
+ tr.style.outline = "";
+ const re = tr.querySelector(".re").value.trim();
+ const fmt = tr.querySelector(".fmt").value.trim();
+ if (!re && !fmt) continue;
+ if (!re || !fmt) {
+ issues.push("rows need both regex and replacement");
+ tr.style.outline = "1px solid #775";
+ continue;
+ }
+ try { new RegExp(re, "i"); } catch (err) {
+ issues.push(`invalid regex: ${err.message}`);
+ tr.style.outline = "1px solid #775";
+ }
+ }
+ status.textContent = issues.length ? issues.join("; ") : `${readNormalizers().length} normalizer row(s) look valid`;
+ updateSectionSummaries();
+});
+
+// ---------- custom part detectors ----------
+
+const PART_DETECTOR_SAMPLES = [
+ "KV-118 - Aiba Reika_PART1.mp4",
+ "KV-118 - Aiba Reika_PART2.mp4",
+ "KV-118 - Aiba Reika_PART3.mp4",
+ "KV-118_1.mp4",
+ "KV-118_2.mp4",
+ "KV-118-pt1.mp4",
+ "KV-118-part2.mp4",
+ "KV-118-cd1.mp4",
+ "KV-118-disc2.mp4",
+ "KV-118 (1).mp4",
+ "KV-118 (1 of 3).mp4",
+ "KV-118.1of3.mp4",
+ "KV-118-2 of 4.mp4",
+ "OFJE-195-1 [480p].mp4",
+ "OFJE-195-2 [480p].mp4",
+ "OFJE-195-3 [480p].mp4",
+ "KV-118_A.mp4",
+ "KV-118-B.mp4",
+ "KV-118A.mp4",
+ "KV-118 1.mp4",
+ "KV-118-P1.mp4",
+ "KV-118_P2.mp4",
+ "KV-118 Part 3.mp4",
+ "KV-118_EP1.mp4",
+ "KV-118 Episode 2.mp4",
+ "KV-118_Vol1.mp4",
+ "KV-118 Volume 2.mp4",
+ "KV-118_Scene1.mp4",
+ "KV-118_Side-A.mp4",
+];
+
+const BUILTIN_PART_DETECTORS = [
+ { pattern: "[-_ ](?:pt|part|cd|disc)[-_ ]?(\\d+)$", note: "pt / part / cd / disc number" },
+ { pattern: "\\s*\\((\\d+)(?:\\s*of\\s*\\d+)?\\)$", note: "parenthesized part number or X of Y" },
+ { pattern: "[._ -](\\d+)\\s*of\\s*\\d+$", note: "X of Y suffix" },
+ { pattern: "_(\\d{1,2})$", note: "underscore number" },
+ { pattern: "-(\\d{1,2})$", note: "hyphen short part number" },
+ { pattern: "[-_]([A-D])$", note: "lettered part with separator" },
+ { pattern: "(?<=\\d)([A-D])$", note: "lettered part directly after ID" },
+ { pattern: "\\s+(\\d{1,2})$", note: "trailing spaced number" },
+];
+
+function partDetectorStem(filename) {
+ return filename.replace(/\.[^.]+$/, "");
+}
+
+function partDetectorStemStages(filename) {
+ const raw = partDetectorStem(filename);
+ const resolutionClean = raw.replace(/\s*\[[^\]]*\]\s*$/, "").trim();
+ let actressClean = resolutionClean;
+ if (actressClean.includes(" - ")) actressClean = actressClean.slice(0, actressClean.indexOf(" - ")).trim();
+ const stages = [];
+ for (const [label, stem] of [
+ ["raw stem", raw],
+ ["after trailing metadata cleanup", resolutionClean],
+ ["after actress cleanup", actressClean],
+ ]) {
+ if (stem && !stages.some((stage) => stage.stem === stem)) stages.push({ label, stem });
+ }
+ return stages;
+}
+
+function partDetectorRegex(pattern) {
+ // Custom detectors are Python regexes, but the common detector subset is
+ // shared with browser RegExp. Preview the representative shapes here; rc-jav
+ // remains authoritative when the saved rule runs during scan/search.
+ return new RegExp(pattern, "i");
+}
+
+function builtinPartCoverage(filename) {
+ for (const detector of BUILTIN_PART_DETECTORS) {
+ try {
+ const re = partDetectorRegex(detector.pattern);
+ for (const stage of partDetectorStemStages(filename)) {
+ const match = stage.stem.match(re);
+ if (match && match[1]) return detector;
+ }
+ } catch {}
+ }
+ return null;
+}
+
+function updatePartDetectorFeedback(row) {
+ const feedback = row.querySelector(".part-detector-feedback");
+ const pattern = row.querySelector(".part-detector-pattern").value.trim();
+ if (!pattern) {
+ feedback.innerHTML = `Enter a detector regex. Capture group 1 should be the part token.`;
+ return;
+ }
+ let re;
+ try {
+ re = partDetectorRegex(pattern);
+ } catch (err) {
+ feedback.innerHTML = `Invalid preview regex: ${escapeHtml(err.message || String(err))}`;
+ return;
+ }
+ const matches = [];
+ let missingCapture = false;
+ for (const filename of PART_DETECTOR_SAMPLES) {
+ for (const stage of partDetectorStemStages(filename)) {
+ const match = stage.stem.match(re);
+ if (!match) continue;
+ if (!match[1]) missingCapture = true;
+ matches.push({ filename, part: match[1] || "?", stage: stage.label });
+ break;
+ }
+ }
+ if (!matches.length) {
+ feedback.innerHTML = `No representative sample matched. The rule may still be valid for a library-specific filename shape.`;
+ return;
+ }
+ const isBuiltin = row.classList.contains("builtin");
+ const covered = !isBuiltin ? matches.map((item) => ({ item, detector: builtinPartCoverage(item.filename) })) : [];
+ const alreadyCovered = covered.length && covered.every((entry) => entry.detector);
+ const coveredNote = alreadyCovered
+ ? `These representative matches are already covered by built-in detector${new Set(covered.map((entry) => entry.detector.pattern)).size === 1 ? "" : "s"}.
`
+ : "";
+ feedback.innerHTML = [
+ `${missingCapture ? "Matched, but capture group 1 was missing for a sample." : `Matches ${matches.length} representative filename shape${matches.length === 1 ? "" : "s"}.`} `,
+ coveredNote,
+ ...matches.slice(0, 4).map((item) => `${escapeHtml(item.filename)} -> part ${escapeHtml(item.part)} (${escapeHtml(item.stage)})
`),
+ matches.length > 4 ? `and ${escapeHtml(matches.length - 4)} more representative match(es)
` : "",
+ ].filter(Boolean).join("");
+}
+
+function addPartDetectorRow(pattern = "", { builtin = false, note = "" } = {}) {
+ const list = document.getElementById(builtin ? "builtin-part-detectors" : "part-detectors");
+ const row = document.createElement("div");
+ row.className = "part-detector-row" + (builtin ? " builtin" : "");
+ row.innerHTML = `
+
+
+ ${builtin ? `Built in ` : `x `}
+
+ ${note ? `${escapeHtml(note)}
` : ""}
+
+ `;
+ row.querySelector(".part-detector-pattern").value = pattern;
+ if (!builtin) {
+ row.querySelector(".part-detector-pattern").addEventListener("input", () => {
+ updatePartDetectorFeedback(row);
+ updateSectionSummaries();
+ });
+ row.querySelector("button").addEventListener("click", () => {
+ row.remove();
+ if (!list.children.length) addPartDetectorRow("");
+ updateSectionSummaries();
+ });
+ }
+ list.appendChild(row);
+ updatePartDetectorFeedback(row);
+ return row;
+}
+
+function renderPartDetectors(patterns) {
+ const builtinList = document.getElementById("builtin-part-detectors");
+ const list = document.getElementById("part-detectors");
+ builtinList.innerHTML = "";
+ list.innerHTML = "";
+ for (const detector of BUILTIN_PART_DETECTORS) addPartDetectorRow(detector.pattern, { builtin: true, note: detector.note });
+ for (const pattern of patterns || []) addPartDetectorRow(pattern);
+ if (!list.children.length) addPartDetectorRow("");
+}
+
+function readPartDetectors() {
+ return [...document.querySelectorAll("#part-detectors .part-detector-pattern")]
+ .map((input) => input.value.trim())
+ .filter(Boolean);
+}
+
+document.getElementById("add-part-detector").addEventListener("click", () => {
+ addPartDetectorRow("").querySelector(".part-detector-pattern").focus();
+});
+
+// Tester
+document.getElementById("norm-test-run").addEventListener("click", async () => {
+ const input = document.getElementById("norm-test-in").value;
+ const out = document.getElementById("norm-test-out");
+ out.textContent = "testing text...";
+ try {
+ const r = await chrome.runtime.sendMessage({
+ type: "test-id-text",
+ text: input,
+ normalizers: readNormalizers(),
+ });
+ if (!r || !r.ok) {
+ out.innerHTML = `error: ${escapeHtml(r?.error || "no response")}`;
+ return;
+ }
+ const e = r.extracted || {};
+ out.innerHTML = [
+ `ID: ${escapeHtml(e.id || "none")}
`,
+ `Rule: ${escapeHtml(e.source || "none")}
`,
+ e.pattern ? `Pattern: ${escapeHtml(e.pattern)}
` : "",
+ e.replacement ? `Replacement: ${escapeHtml(e.replacement)}
` : "",
+ e.raw ? `Raw: ${escapeHtml(e.raw)}
` : "",
+ ].filter(Boolean).join("");
+ } catch (err) {
+ out.innerHTML = `error: ${escapeHtml(err.message || String(err))}`;
+ }
+});
+
diff --git a/options.html b/options.html
index 74f3f86..482ed48 100644
--- a/options.html
+++ b/options.html
@@ -794,6 +794,9 @@
+
+
+