3134 lines
138 KiB
JavaScript
3134 lines
138 KiB
JavaScript
const KEYS = [
|
||
"autoEveryPage",
|
||
"autoKnownSites",
|
||
"autoPageLoad",
|
||
"autoSpaNavigation",
|
||
"toolbarClick",
|
||
"contextMenu",
|
||
"keyboardShortcut",
|
||
];
|
||
|
||
// MUST match DEFAULT_SETTINGS in background.js.
|
||
const DEFAULT_TRIGGERS = {
|
||
autoEveryPage: false,
|
||
autoKnownSites: false,
|
||
autoPageLoad: true,
|
||
autoSpaNavigation: true,
|
||
toolbarClick: true,
|
||
contextMenu: true,
|
||
keyboardShortcut: true,
|
||
};
|
||
|
||
// ---------- sidebar nav ----------
|
||
function activatePane(pane) {
|
||
if (pane === "backup") pane = "paths";
|
||
if (pane === "review") pane = "maintenance";
|
||
const item = document.querySelector(`.side .item[data-pane="${pane}"]`) || document.querySelector('.side .item[data-pane="triggers"]');
|
||
if (!item) return;
|
||
document.querySelectorAll(".side .item").forEach((i) => i.classList.remove("active"));
|
||
item.classList.add("active");
|
||
document.querySelectorAll(".pane").forEach((p) => p.classList.remove("active"));
|
||
const paneEl = document.getElementById("pane-" + item.dataset.pane);
|
||
if (paneEl) paneEl.classList.add("active");
|
||
}
|
||
|
||
for (const item of document.querySelectorAll(".side .item")) {
|
||
item.addEventListener("click", async () => {
|
||
activatePane(item.dataset.pane);
|
||
await chrome.storage.local.set({ optionsActivePane: item.dataset.pane });
|
||
});
|
||
}
|
||
|
||
function getActionScrollContainer(element) {
|
||
for (let node = element?.parentElement; node; node = node.parentElement) {
|
||
const overflowY = getComputedStyle(node).overflowY;
|
||
if ((overflowY === "auto" || overflowY === "scroll") && node.scrollHeight > node.clientHeight) {
|
||
return node;
|
||
}
|
||
}
|
||
return document.scrollingElement || document.documentElement;
|
||
}
|
||
|
||
function nextAnimationFrame() {
|
||
return new Promise((resolve) => requestAnimationFrame(resolve));
|
||
}
|
||
|
||
function openModal(id) {
|
||
const modal = document.getElementById(id);
|
||
if (!modal) return;
|
||
modal.classList.add("open");
|
||
modal.setAttribute("aria-hidden", "false");
|
||
}
|
||
|
||
function closeModal(id) {
|
||
const modal = document.getElementById(id);
|
||
if (!modal) return;
|
||
modal.classList.remove("open");
|
||
modal.setAttribute("aria-hidden", "true");
|
||
}
|
||
|
||
async function keepActionViewport(action, run) {
|
||
const beforeTop = action?.getBoundingClientRect().top;
|
||
const scroller = getActionScrollContainer(action);
|
||
try {
|
||
return await run();
|
||
} finally {
|
||
await nextAnimationFrame();
|
||
await nextAnimationFrame();
|
||
if (!action?.isConnected || !Number.isFinite(beforeTop)) return;
|
||
const delta = action.getBoundingClientRect().top - beforeTop;
|
||
if (Math.abs(delta) < 1) return;
|
||
if (scroller === document.scrollingElement || scroller === document.documentElement) {
|
||
window.scrollBy(0, delta);
|
||
} else {
|
||
scroller.scrollTop += delta;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- overlay tabs ----------
|
||
for (const tab of document.querySelectorAll(".otab")) {
|
||
tab.addEventListener("click", () => {
|
||
document.querySelectorAll(".otab").forEach((t) => t.classList.remove("active"));
|
||
document.querySelectorAll(".otab-panel").forEach((p) => p.classList.remove("active"));
|
||
tab.classList.add("active");
|
||
document.getElementById("otab-" + tab.dataset.otab).classList.add("active");
|
||
});
|
||
}
|
||
|
||
// ---------- settings load / save ----------
|
||
|
||
async function load() {
|
||
const { settings = {} } = await chrome.storage.sync.get("settings");
|
||
const t = Object.assign({}, DEFAULT_TRIGGERS, settings.triggers || {});
|
||
for (const k of KEYS) document.getElementById(k).checked = !!t[k];
|
||
document.getElementById("quickMode").checked = settings.quickMode !== false;
|
||
document.getElementById("cacheStaleHours").value = Math.max(1, Number(settings.cacheStaleHours) || 24);
|
||
document.getElementById("showOverlay").checked = settings.showOverlay !== false;
|
||
// Overlay options
|
||
const pos = settings.overlayPosition || "top-right";
|
||
for (const r of document.querySelectorAll('input[name="overlayPosition"]')) {
|
||
r.checked = r.value === pos;
|
||
}
|
||
syncOverlayPosChips();
|
||
document.getElementById("overlayDuration").value = settings.overlayDuration || 5;
|
||
document.getElementById("overlayGlow").checked = !!settings.overlayGlow;
|
||
document.getElementById("overlayGlowColor").value = settings.overlayGlowColor || "#6ec1ff";
|
||
document.getElementById("overlayGlowBlur").value = settings.overlayGlowBlur ?? 10;
|
||
document.getElementById("overlayGlowSpread").value = settings.overlayGlowSpread ?? 0;
|
||
document.getElementById("overlayGlowOpacity").value = settings.overlayGlowOpacity ?? 0.35;
|
||
// no-match overlay
|
||
document.getElementById("noMatchOverlay").checked = !!settings.noMatchOverlay;
|
||
const nmPos = settings.noMatchPosition || "top-right";
|
||
for (const r of document.querySelectorAll('input[name="noMatchPosition"]')) {
|
||
r.checked = r.value === nmPos;
|
||
}
|
||
syncNoMatchPosChips();
|
||
document.getElementById("noMatchDuration").value = settings.noMatchDuration || 5;
|
||
document.getElementById("noMatchGlow").checked = !!settings.noMatchGlow;
|
||
document.getElementById("noMatchGlowColor").value = settings.noMatchGlowColor || "#ff6666";
|
||
document.getElementById("noMatchGlowBlur").value = settings.noMatchGlowBlur ?? 10;
|
||
document.getElementById("noMatchGlowSpread").value = settings.noMatchGlowSpread ?? 0;
|
||
document.getElementById("noMatchGlowOpacity").value = settings.noMatchGlowOpacity ?? 0.35;
|
||
if (typeof updateOverlayPreview === "function") updateOverlayPreview();
|
||
if (typeof updateNoMatchPreview === "function") updateNoMatchPreview();
|
||
if (typeof updateMutualExclusion === "function") updateMutualExclusion();
|
||
document.getElementById("knownSitePatterns").value = (settings.knownSitePatterns || []).join("\n");
|
||
renderAdapters(settings.siteAdapters || []);
|
||
renderNormalizers(settings.idNormalizers || []);
|
||
renderPartDetectors(settings.partPatterns || []);
|
||
document.getElementById("enableDelete").checked = !!settings.enableDelete;
|
||
const mode = settings.deleteMode === "permanent" ? "permanent" : "trash";
|
||
document.getElementById("deleteModeTrash").checked = mode === "trash";
|
||
document.getElementById("deleteModePerm").checked = mode === "permanent";
|
||
syncRadioChips();
|
||
document.getElementById("trashDir").value = settings.trashDir || "cq:personal-files/.rclone-jav-trash";
|
||
document.getElementById("rcjavPath").value = settings.rcjavPath || "";
|
||
renderProfiles(settings.profiles || []);
|
||
updateSectionSummaries();
|
||
syncDeletionControls();
|
||
}
|
||
|
||
async function save(e) {
|
||
const { settings: existingSettings = {} } = await chrome.storage.sync.get("settings");
|
||
const triggers = {};
|
||
for (const k of KEYS) triggers[k] = document.getElementById(k).checked;
|
||
const knownSitePatterns = document.getElementById("knownSitePatterns").value
|
||
.split(/[\n,]/).map((s) => s.trim()).filter(Boolean);
|
||
const quickMode = document.getElementById("quickMode").checked;
|
||
const cacheStaleHours = Math.max(1, Math.min(8760, parseInt(document.getElementById("cacheStaleHours").value, 10) || 24));
|
||
const showOverlay = document.getElementById("showOverlay").checked;
|
||
const overlayPosition = (document.querySelector('input[name="overlayPosition"]:checked') || {}).value || "top-right";
|
||
const overlayDuration = Math.max(1, parseInt(document.getElementById("overlayDuration").value, 10) || 5);
|
||
const overlayGlow = document.getElementById("overlayGlow").checked;
|
||
const overlayGlowColor = document.getElementById("overlayGlowColor").value || "#6ec1ff";
|
||
const overlayGlowBlur = parseInt(document.getElementById("overlayGlowBlur").value, 10);
|
||
const overlayGlowSpread = parseInt(document.getElementById("overlayGlowSpread").value, 10);
|
||
const overlayGlowOpacity = parseFloat(document.getElementById("overlayGlowOpacity").value);
|
||
const noMatchOverlay = document.getElementById("noMatchOverlay").checked;
|
||
const noMatchPosition = (document.querySelector('input[name="noMatchPosition"]:checked') || {}).value || "top-right";
|
||
const noMatchDuration = Math.max(1, parseInt(document.getElementById("noMatchDuration").value, 10) || 5);
|
||
const noMatchGlow = document.getElementById("noMatchGlow").checked;
|
||
const noMatchGlowColor = document.getElementById("noMatchGlowColor").value || "#ff6666";
|
||
const noMatchGlowBlur = parseInt(document.getElementById("noMatchGlowBlur").value, 10);
|
||
const noMatchGlowSpread = parseInt(document.getElementById("noMatchGlowSpread").value, 10);
|
||
const noMatchGlowOpacity = parseFloat(document.getElementById("noMatchGlowOpacity").value);
|
||
const siteAdapters = readAdapters();
|
||
const idNormalizers = readNormalizers();
|
||
const partPatterns = readPartDetectors();
|
||
const enableDelete = document.getElementById("enableDelete").checked;
|
||
const deleteMode = document.getElementById("deleteModePerm").checked ? "permanent" : "trash";
|
||
const trashDir = document.getElementById("trashDir").value.trim() || "cq:personal-files/.rclone-jav-trash";
|
||
const rcjavPath = document.getElementById("rcjavPath").value.trim();
|
||
const payload = {
|
||
triggers, knownSitePatterns, quickMode, cacheStaleHours,
|
||
showOverlay, overlayPosition, overlayDuration, overlayGlow, overlayGlowColor, overlayGlowBlur, overlayGlowSpread, overlayGlowOpacity,
|
||
noMatchOverlay, noMatchPosition, noMatchDuration, noMatchGlow, noMatchGlowColor, noMatchGlowBlur, noMatchGlowSpread, noMatchGlowOpacity,
|
||
siteAdapters, idNormalizers, partPatterns, enableDelete, deleteMode, trashDir, rcjavPath,
|
||
profiles: readProfiles(),
|
||
activeProfile: existingSettings.activeProfile || "",
|
||
scanPaused: !!existingSettings.scanPaused,
|
||
};
|
||
const btn = e && e.target ? e.target : null;
|
||
const saved = btn && btn.nextElementSibling;
|
||
try {
|
||
await chrome.storage.sync.set({ settings: payload });
|
||
} catch (err) {
|
||
// chrome.storage.sync has 8 KB/item + 100 KB total quota. Long adapter or
|
||
// normalizer lists can blow it; without this try/catch the rejection is
|
||
// silently swallowed and the "Saved." chip never appears.
|
||
if (saved && saved.classList.contains("saved")) {
|
||
saved.textContent = "Save failed: " + (err.message || String(err));
|
||
saved.classList.add("show", "error");
|
||
setTimeout(() => { saved.classList.remove("show", "error"); saved.textContent = "Saved."; }, 4000);
|
||
} else {
|
||
alert("Failed to save settings: " + (err.message || String(err)));
|
||
}
|
||
return;
|
||
}
|
||
chrome.runtime.sendMessage({ type: "settings-changed" });
|
||
updateSectionSummaries();
|
||
|
||
// Show "Saved." chip on the button that triggered this
|
||
if (saved && saved.classList.contains("saved")) {
|
||
saved.classList.add("show");
|
||
setTimeout(() => saved.classList.remove("show"), 1500);
|
||
}
|
||
}
|
||
|
||
for (const btn of document.querySelectorAll(".save-btn")) {
|
||
btn.addEventListener("click", save);
|
||
}
|
||
|
||
function plural(n, word) {
|
||
return `${n} ${word}${n === 1 ? "" : "s"}`;
|
||
}
|
||
|
||
function setNote(id, html, kind = "") {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
el.className = "section-note" + (kind ? ` ${kind}` : "");
|
||
el.innerHTML = html;
|
||
}
|
||
|
||
function updateSectionSummaries() {
|
||
const knownSites = document.getElementById("knownSitePatterns").value
|
||
.split(/[\n,]/).map((s) => s.trim()).filter(Boolean);
|
||
const triggersOn = KEYS.filter((k) => document.getElementById(k)?.checked);
|
||
const autoOn = document.getElementById("autoEveryPage").checked || document.getElementById("autoKnownSites").checked;
|
||
setNote("trigger-summary",
|
||
`${autoOn ? "Auto-check is enabled" : "Auto-check is off"} · ${plural(knownSites.length, "known site")} · ${plural(triggersOn.length, "manual/navigation control")} enabled`,
|
||
autoOn ? "" : "warn");
|
||
|
||
const match = document.getElementById("showOverlay").checked;
|
||
const noMatch = document.getElementById("noMatchOverlay").checked;
|
||
setNote("overlay-summary", `Match overlay ${match ? "on" : "off"} · no-match overlay ${noMatch ? "on" : "off"} · positions are kept separate to avoid overlap`, match || noMatch ? "" : "warn");
|
||
|
||
const adapters = readAdapters();
|
||
setNote("adapter-summary", `${plural(adapters.length, "custom adapter")} configured · built-in ClearJAV preset always runs`, adapters.length ? "" : "info");
|
||
|
||
const normalizers = readNormalizers();
|
||
const partPatterns = readPartDetectors();
|
||
setNote("normalizer-summary", `${plural(normalizers.length, "custom normalizer")} · ${plural(partPatterns.length, "custom part detector")} · built-in FC2 and multipart rules active`, normalizers.length || partPatterns.length ? "" : "info");
|
||
|
||
const quick = document.getElementById("quickMode").checked;
|
||
setNote("search-summary", quick ? "LIVE mode: single-ID lookups query rclone directly and bypass cache." : "CACHE mode: lookups use cache.json; rebuild cache after library or ID-normalization changes.");
|
||
|
||
const path = document.getElementById("rcjavPath").value.trim();
|
||
setNote("paths-summary", path ? `Using extension override: <code>${escapeHtml(path)}</code>` : "Using native host default rc-jav path.", path ? "" : "info");
|
||
|
||
const profiles = readProfiles();
|
||
setNote("profiles-summary", profiles.length ? `${plural(profiles.length, "profile")} configured. Empty source/target lists inherit config.json defaults.` : "No profiles configured. Popup searches use rc-jav config.json defaults.", profiles.length ? "" : "info");
|
||
|
||
const settingCount = Object.keys(SETTINGS_SCHEMA).length;
|
||
setNote("backup-summary", `Backups include ${settingCount} recognized settings keys. Export before moving the extension or changing its ID.`, "info");
|
||
|
||
const deleteOn = document.getElementById("enableDelete").checked;
|
||
const mode = document.getElementById("deleteModePerm").checked ? "permanent delete" : "trash mode";
|
||
setNote("deletion-summary", deleteOn ? `Deletion is enabled in ${mode}. Popup confirmation still requires typing the exact filename.` : "Deletion is disabled. Popup delete controls stay hidden.", deleteOn ? "danger" : "");
|
||
}
|
||
|
||
function syncDeletionControls() {
|
||
const enabled = document.getElementById("enableDelete").checked;
|
||
for (const id of ["deleteModeTrash", "deleteModePerm", "trashDir"]) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.disabled = !enabled;
|
||
}
|
||
for (const el of [document.getElementById("deleteModeTrashLbl")?.closest(".fieldset"), document.getElementById("trashDir")?.closest(".fieldset")]) {
|
||
if (el) el.classList.toggle("disabled-soft", !enabled);
|
||
}
|
||
}
|
||
|
||
document.getElementById("enableDelete").addEventListener("change", (event) => {
|
||
if (!event.target.checked) return;
|
||
event.target.checked = false;
|
||
syncDeletionControls();
|
||
updateSectionSummaries();
|
||
openModal("delete-enable-modal");
|
||
});
|
||
function closeDeleteEnableModal() {
|
||
closeModal("delete-enable-modal");
|
||
}
|
||
document.getElementById("delete-enable-modal-confirm").addEventListener("click", () => {
|
||
document.getElementById("enableDelete").checked = true;
|
||
closeDeleteEnableModal();
|
||
syncDeletionControls();
|
||
updateSectionSummaries();
|
||
});
|
||
for (const id of ["delete-enable-modal-close", "delete-enable-modal-cancel"]) {
|
||
document.getElementById(id).addEventListener("click", closeDeleteEnableModal);
|
||
}
|
||
document.getElementById("delete-enable-modal").addEventListener("click", (event) => {
|
||
if (event.target.id === "delete-enable-modal") closeDeleteEnableModal();
|
||
});
|
||
|
||
document.addEventListener("input", (e) => {
|
||
if (e.target && e.target.closest(".pane")) updateSectionSummaries();
|
||
});
|
||
document.addEventListener("change", (e) => {
|
||
if (e.target && e.target.closest(".pane")) {
|
||
if (e.target.id === "enableDelete") syncDeletionControls();
|
||
updateSectionSummaries();
|
||
}
|
||
});
|
||
|
||
// ---------- backup & restore ----------
|
||
|
||
document.getElementById("export-settings").addEventListener("click", async () => {
|
||
const { settings = {} } = await chrome.storage.sync.get("settings");
|
||
const payload = {
|
||
_meta: { app: "rclonex", exported_at: new Date().toISOString(), version: 1 },
|
||
settings,
|
||
};
|
||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||
a.href = url;
|
||
a.download = `rclonex-settings-${stamp}.json`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
setBackupStatus("exported.", "ok");
|
||
});
|
||
|
||
document.getElementById("import-settings").addEventListener("click", () => {
|
||
document.getElementById("import-file").click();
|
||
});
|
||
|
||
// Allowlist of settings keys with their expected primitive types. Imports
|
||
// containing any other key are dropped silently; primitives must match.
|
||
// Nested objects (triggers, siteAdapters[].*) get a recursive shallow check.
|
||
const SETTINGS_SCHEMA = {
|
||
triggers: "object",
|
||
knownSitePatterns: "array",
|
||
quickMode: "boolean",
|
||
cacheStaleHours: "number",
|
||
scanPaused: "boolean",
|
||
rcjavPath: "string",
|
||
showOverlay: "boolean",
|
||
overlayPosition: "string",
|
||
overlayDuration: "number",
|
||
overlayGlow: "boolean",
|
||
overlayGlowColor: "string",
|
||
overlayGlowBlur: "number",
|
||
overlayGlowSpread: "number",
|
||
overlayGlowOpacity: "number",
|
||
noMatchOverlay: "boolean",
|
||
noMatchPosition: "string",
|
||
noMatchDuration: "number",
|
||
noMatchGlow: "boolean",
|
||
noMatchGlowColor: "string",
|
||
noMatchGlowBlur: "number",
|
||
noMatchGlowSpread: "number",
|
||
noMatchGlowOpacity: "number",
|
||
enableDelete: "boolean",
|
||
deleteMode: "string",
|
||
trashDir: "string",
|
||
idNormalizers: "array",
|
||
partPatterns: "array",
|
||
siteAdapters: "array",
|
||
profiles: "array",
|
||
activeProfile: "string",
|
||
};
|
||
|
||
function _typeOf(v) {
|
||
if (Array.isArray(v)) return "array";
|
||
if (v === null) return "null";
|
||
return typeof v;
|
||
}
|
||
|
||
function sanitizeImportedSettings(incoming) {
|
||
if (typeof incoming !== "object" || incoming === null || Array.isArray(incoming)) {
|
||
throw new Error("settings must be a JSON object");
|
||
}
|
||
const out = {};
|
||
const dropped = [];
|
||
for (const [k, v] of Object.entries(incoming)) {
|
||
const expected = SETTINGS_SCHEMA[k];
|
||
if (!expected) { dropped.push(k); continue; }
|
||
if (_typeOf(v) !== expected) { dropped.push(`${k}(wrong type)`); continue; }
|
||
out[k] = v;
|
||
}
|
||
return { sanitized: out, dropped };
|
||
}
|
||
|
||
let pendingImport = null;
|
||
|
||
function closeImportModal() {
|
||
pendingImport = null;
|
||
closeModal("import-modal");
|
||
}
|
||
|
||
function openImportModal(fileName, sanitized, dropped) {
|
||
pendingImport = { sanitized, dropped };
|
||
document.getElementById("import-modal-subtitle").textContent = fileName || "settings JSON";
|
||
document.getElementById("import-modal-body").innerHTML = `
|
||
<div class="modal-help">Review this import before it replaces the current extension settings.</div>
|
||
<div class="diag-row warn"><span class="icon">!</span><span class="name">Overwrite</span><span class="detail">Current settings will be replaced by ${escapeHtml(Object.keys(sanitized).length)} imported value(s).</span></div>
|
||
<div class="diag-row info"><span class="icon">i</span><span class="name">Profiles</span><span class="detail">${escapeHtml(Array.isArray(sanitized.profiles) ? `${sanitized.profiles.length} profile(s) in this import` : "No profile list in this import")}</span></div>
|
||
<div class="diag-row ${sanitized.enableDelete ? "warn" : "info"}"><span class="icon">${sanitized.enableDelete ? "!" : "i"}</span><span class="name">Deletion</span><span class="detail">${escapeHtml(sanitized.enableDelete ? `Enabled in imported settings (${sanitized.deleteMode || "trash"} mode)` : "Not enabled by this import")}</span></div>
|
||
${dropped.length ? `<div class="diag-row warn"><span class="icon">!</span><span class="name">Ignored keys</span><span class="detail">${escapeHtml(`${dropped.length}: ${dropped.slice(0, 8).join(", ")}${dropped.length > 8 ? "..." : ""}`)}</span></div>` : `<div class="diag-row ok"><span class="icon">✓</span><span class="name">Schema</span><span class="detail">All imported keys are recognized.</span></div>`}
|
||
`;
|
||
openModal("import-modal");
|
||
}
|
||
|
||
document.getElementById("import-file").addEventListener("change", async (e) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
try {
|
||
const text = await file.text();
|
||
const data = JSON.parse(text);
|
||
const incoming = data.settings || data;
|
||
const { sanitized, dropped } = sanitizeImportedSettings(incoming);
|
||
if (Object.keys(sanitized).length === 0) {
|
||
throw new Error("no recognized settings keys in file");
|
||
}
|
||
openImportModal(file.name, sanitized, dropped);
|
||
} catch (err) {
|
||
setBackupStatus("import failed: " + err.message, "fail");
|
||
}
|
||
e.target.value = "";
|
||
});
|
||
|
||
document.getElementById("import-modal-confirm").addEventListener("click", async () => {
|
||
if (!pendingImport) return;
|
||
const { sanitized, dropped } = pendingImport;
|
||
try {
|
||
await chrome.storage.sync.set({ settings: sanitized });
|
||
chrome.runtime.sendMessage({ type: "settings-changed" });
|
||
closeImportModal();
|
||
setBackupStatus(`imported${dropped.length ? ` (${dropped.length} key(s) dropped)` : ""}. reloading...`, "ok");
|
||
setTimeout(() => location.reload(), 600);
|
||
} catch (err) {
|
||
setBackupStatus("import failed: " + (err.message || String(err)), "fail");
|
||
}
|
||
});
|
||
document.getElementById("import-modal-close").addEventListener("click", closeImportModal);
|
||
document.getElementById("import-modal-cancel").addEventListener("click", () => {
|
||
setBackupStatus("cancelled.", "warn");
|
||
closeImportModal();
|
||
});
|
||
document.getElementById("import-modal").addEventListener("click", (event) => {
|
||
if (event.target.id === "import-modal") closeImportModal();
|
||
});
|
||
|
||
function setBackupStatus(msg, kind) {
|
||
const el = document.getElementById("backup-status");
|
||
el.textContent = msg;
|
||
el.style.color = kind === "ok" ? "#afa" : kind === "fail" ? "#faa" : "#ffa";
|
||
setTimeout(() => { el.textContent = ""; }, 4000);
|
||
}
|
||
|
||
function appendListValue(textareaId, value) {
|
||
const el = document.getElementById(textareaId);
|
||
const parts = el.value.split(/[\n,]/).map((s) => s.trim()).filter(Boolean);
|
||
if (!parts.some((p) => p.toLowerCase() === value.toLowerCase())) parts.push(value);
|
||
el.value = parts.join("\n");
|
||
updateSectionSummaries();
|
||
}
|
||
|
||
document.getElementById("add-clearjav-site").addEventListener("click", () => {
|
||
appendListValue("knownSitePatterns", "clearjav.com");
|
||
document.getElementById("known-site-status").textContent = "added clearjav.com";
|
||
});
|
||
|
||
document.getElementById("add-current-site").addEventListener("click", async () => {
|
||
const status = document.getElementById("known-site-status");
|
||
status.textContent = "finding current site...";
|
||
const all = await chrome.tabs.query({});
|
||
const web = all.filter((t) => t.url && /^https?:/.test(t.url));
|
||
web.sort((a, b) => (b.lastAccessed || 0) - (a.lastAccessed || 0));
|
||
const tab = web[0];
|
||
if (!tab) {
|
||
status.textContent = "no http/https tab found";
|
||
return;
|
||
}
|
||
try {
|
||
const host = new URL(tab.url).hostname.replace(/^www\./, "");
|
||
appendListValue("knownSitePatterns", host);
|
||
status.textContent = `added ${host}`;
|
||
} catch {
|
||
status.textContent = "could not read active site";
|
||
}
|
||
});
|
||
|
||
// ---------- cache status ----------
|
||
|
||
function fmtCacheAge(hours) {
|
||
if (!Number.isFinite(hours)) return "?";
|
||
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
||
if (hours < 24) return `${hours.toFixed(1)}h`;
|
||
return `${(hours / 24).toFixed(1)}d`;
|
||
}
|
||
|
||
let _configuredScanRoots = [];
|
||
let _cacheSkippedByRemote = new Map();
|
||
let _skippedModalText = "";
|
||
|
||
function rememberConfiguredScanRoots(r) {
|
||
_configuredScanRoots = [
|
||
...(r?.configured?.default_source || []),
|
||
...(r?.configured?.default_target || []),
|
||
];
|
||
}
|
||
|
||
function setupHealthRow(level, name, detail) {
|
||
const icon = level === "ok" ? "✓" : level === "warn" ? "!" : level === "fail" ? "✗" : "i";
|
||
return `<div class="diag-row ${level}"><span class="icon">${icon}</span><span class="name">${escapeHtml(name)}</span><span class="detail">${escapeHtml(detail)}</span></div>`;
|
||
}
|
||
|
||
function openSkippedModal(remote) {
|
||
const items = _cacheSkippedByRemote.get(remote) || [];
|
||
const summary = document.getElementById("skipped-modal-summary");
|
||
const list = document.getElementById("skipped-modal-list");
|
||
document.getElementById("skipped-modal-subtitle").textContent = `${remote} · ${items.length} skipped`;
|
||
const reasonCounts = new Map();
|
||
for (const item of items) reasonCounts.set(item.reason || "unparsed ID", (reasonCounts.get(item.reason || "unparsed ID") || 0) + 1);
|
||
summary.innerHTML = [...reasonCounts.entries()]
|
||
.sort((a, b) => b[1] - a[1])
|
||
.map(([reason, count]) => `<span>${escapeHtml(count)} ${escapeHtml(reason)}</span>`)
|
||
.join("");
|
||
list.innerHTML = items.map((item) => `<div class="skip-row">
|
||
<div class="name">${escapeHtml(item.name || item.path || "?")}</div>
|
||
<div class="reason">${escapeHtml(item.reason || "unparsed ID")}</div>
|
||
<div class="path">${escapeHtml(item.path || "")}</div>
|
||
</div>`).join("") || `<div style="color:#777;">No skipped IDs recorded for this remote.</div>`;
|
||
_skippedModalText = [
|
||
`Skipped IDs for ${remote}`,
|
||
...items.map((item) => `${item.name || item.path || "?"}\t${item.reason || "unparsed ID"}\t${item.path || ""}`),
|
||
].join("\n");
|
||
openModal("skipped-modal");
|
||
}
|
||
|
||
function closeSkippedModal() {
|
||
closeModal("skipped-modal");
|
||
}
|
||
|
||
document.getElementById("skipped-modal-close").addEventListener("click", closeSkippedModal);
|
||
document.getElementById("skipped-modal-done").addEventListener("click", closeSkippedModal);
|
||
document.getElementById("skipped-modal").addEventListener("click", (event) => {
|
||
if (event.target.id === "skipped-modal") closeSkippedModal();
|
||
});
|
||
document.getElementById("skipped-modal-copy").addEventListener("click", async () => {
|
||
if (!_skippedModalText) return;
|
||
await navigator.clipboard.writeText(_skippedModalText);
|
||
const btn = document.getElementById("skipped-modal-copy");
|
||
btn.textContent = "Copied";
|
||
setTimeout(() => { btn.textContent = "Copy List"; }, 1200);
|
||
});
|
||
|
||
document.getElementById("setup-health-run").addEventListener("click", (event) =>
|
||
keepActionViewport(event.currentTarget, async () => {
|
||
const out = document.getElementById("setup-health-results");
|
||
clearNativeRepairCard();
|
||
out.textContent = "checking setup health...";
|
||
const [settings, cache, host] = await Promise.all([
|
||
chrome.runtime.sendMessage({ type: "get-settings" }),
|
||
chrome.runtime.sendMessage({ type: "cache-status" }),
|
||
chrome.runtime.sendMessage({ type: "host-status" }),
|
||
]);
|
||
const rows = [];
|
||
const mode = settings?.quickMode !== false ? "LIVE" : "CACHE";
|
||
rows.push(setupHealthRow(settings?.scanPaused ? "warn" : "ok", "Search state",
|
||
settings?.scanPaused ? `${mode} mode · scanning paused` : `${mode} mode · scanning enabled`));
|
||
rows.push(setupHealthRow("info", "Library profile",
|
||
settings?.activeProfile || "config.json defaults"));
|
||
const nativeBlocked = [cache, host].find((r) => r && !r.ok && r.error_kind);
|
||
if (nativeBlocked) await renderNativeMessagingFailure(nativeBlocked);
|
||
if (!cache?.ok && cache?.error_kind) {
|
||
rows.push(setupHealthRow("warn", "Cache", "Blocked until native host registration is fixed."));
|
||
} else if (!cache?.ok) {
|
||
rows.push(setupHealthRow("fail", "Cache", cache?.error || "cache status unavailable"));
|
||
} else if (!cache.cache_exists) {
|
||
rows.push(setupHealthRow("warn", "Cache", "cache.json missing; cached searches need a rebuild"));
|
||
} else {
|
||
const remotes = cache.remotes || [];
|
||
const stale = remotes.filter((r) => r.stale || r.status === "never_scanned");
|
||
const files = remotes.reduce((sum, r) => sum + Number(r.file_count || 0), 0);
|
||
rows.push(setupHealthRow(stale.length || (cache.warnings || []).length ? "warn" : "ok", "Cache",
|
||
`${files.toLocaleString()} files · ${remotes.length} remote(s) · ${stale.length} stale/unscanned`));
|
||
}
|
||
if (!host?.ok && host?.error_kind) {
|
||
rows.push(setupHealthRow("warn", "Native host", "Registration is required before host checks can run."));
|
||
} else if (!host?.ok) {
|
||
rows.push(setupHealthRow("fail", "Native host", host?.error || "host status unavailable"));
|
||
} else {
|
||
const failed = (host.checks || []).filter((c) => c.status === "fail");
|
||
rows.push(setupHealthRow(failed.length ? "fail" : "ok", "Native host",
|
||
failed.length ? `${failed.length} registration check(s) failed; use Diagnostics` : "registration checks passed"));
|
||
}
|
||
out.innerHTML = rows.join("");
|
||
})
|
||
);
|
||
|
||
document.getElementById("cache-status-run").addEventListener("click", async () => {
|
||
const out = document.getElementById("cache-status-results");
|
||
out.textContent = "checking cache...";
|
||
try {
|
||
const r = await chrome.runtime.sendMessage({ type: "cache-status" });
|
||
if (!r || !r.ok) {
|
||
out.innerHTML = `<span style="color:#faa;">error:</span> ${escapeHtml(r?.error || "no response")}`;
|
||
return;
|
||
}
|
||
rememberConfiguredScanRoots(r);
|
||
_cacheSkippedByRemote = new Map((r.remotes || []).map((m) => [m.remote, m.skipped_items || []]));
|
||
if (!r.cache_exists) {
|
||
const configured = (r.remotes || []).map((m) =>
|
||
`<div style="margin-top:6px;color:#ffa;">! ${escapeHtml(m.remote)} · never scanned</div>`
|
||
);
|
||
out.innerHTML = [
|
||
`<div><span style="color:#ffa;">cache not found</span></div>`,
|
||
`<div>${escapeHtml(r.cache_path || "")}</div>`,
|
||
...configured,
|
||
].join("");
|
||
return;
|
||
}
|
||
const rows = [
|
||
`<div><span style="color:#777;">Path:</span> ${escapeHtml(r.cache_path || "")}</div>`,
|
||
`<div><span style="color:#777;">Version:</span> ${escapeHtml(r.version ?? "?")}</div>`,
|
||
`<div><span style="color:#777;">Stale after:</span> ${escapeHtml(r.stale_hours ?? 24)}h</div>`,
|
||
`<div><span style="color:#777;">Configured target:</span> ${escapeHtml((r.configured?.default_target || []).join(", ") || "(none)")}</div>`,
|
||
`<div><span style="color:#777;">Configured source:</span> ${escapeHtml((r.configured?.default_source || []).join(", ") || "(none)")}</div>`,
|
||
];
|
||
for (const m of r.remotes || []) {
|
||
const color = m.status === "never_scanned" || m.stale ? "#ffa" : "#afa";
|
||
const state = m.status === "never_scanned" ? "never scanned" : `${m.status || (m.stale ? "stale" : "fresh")} · age ${fmtCacheAge(m.age_hours)}`;
|
||
const skippedCount = Number(m.skipped_count) || 0;
|
||
const skippedNote = skippedCount
|
||
? ` · <button class="chip-btn cache-show-skipped" type="button" data-remote="${escapeHtml(m.remote)}" style="color:#ffa;background:rgba(255,200,50,.08);border-color:rgba(255,200,50,.2);">${skippedCount} non-JAV ▾</button>`
|
||
: "";
|
||
rows.push(`<div style="margin-top:6px;"><span style="color:${color};">${escapeHtml(m.remote)}</span> · ${escapeHtml(state)} · ${escapeHtml(m.file_count)} files${skippedNote} <button class="chip-btn cache-refresh-remote" type="button" data-remote="${escapeHtml(m.remote)}">Refresh</button></div>`);
|
||
for (const issue of m.issues || []) {
|
||
rows.push(`<div style="color:#ffa;margin-left:12px;">! ${escapeHtml(issue.count)} ${escapeHtml(issue.message)}</div>`);
|
||
}
|
||
}
|
||
if ((r.warnings || []).length) {
|
||
rows.push(`<div style="margin-top:10px;color:#ffcc44;">Rebuild cache recommended:</div>`);
|
||
for (const w of r.warnings || []) {
|
||
rows.push(`<div style="color:#ffa;margin-left:12px;">! ${escapeHtml(w.message || w.code)}</div>`);
|
||
}
|
||
}
|
||
out.innerHTML = rows.join("");
|
||
} catch (err) {
|
||
out.innerHTML = `<span style="color:#faa;">error:</span> ${escapeHtml(err.message || String(err))}`;
|
||
}
|
||
});
|
||
|
||
// ---------- recent activity ----------
|
||
|
||
function fmtActivityWhen(ts) {
|
||
if (!ts) return "?";
|
||
const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
|
||
if (s < 60) return `${s}s ago`;
|
||
if (s < 3600) return `${Math.round(s / 60)}m ago`;
|
||
if (s < 86400) return `${Math.round(s / 3600)}h ago`;
|
||
return `${Math.round(s / 86400)}d ago`;
|
||
}
|
||
|
||
let recentActivityEntries = [];
|
||
let activityFilter = "all";
|
||
|
||
function activityOutcomeView(outcome) {
|
||
if (outcome === "hit") return { label: "Match", cls: "hit" };
|
||
if (outcome === "miss") return { label: "No Match", cls: "miss" };
|
||
if (outcome === "no_id") return { label: "No ID", cls: "no-id" };
|
||
if (outcome === "paused") return { label: "Paused", cls: "paused" };
|
||
return { label: outcome === "error" ? "Error" : (outcome || "Unknown"), cls: "error" };
|
||
}
|
||
|
||
function activityMatchesFilter(entry) {
|
||
if (activityFilter === "all") return true;
|
||
if (activityFilter === "other") return !["hit", "miss", "no_id"].includes(entry.outcome);
|
||
return entry.outcome === activityFilter;
|
||
}
|
||
|
||
function updateActivityFilterButtons() {
|
||
document.querySelectorAll("#activity-filters .activity-filter").forEach((btn) => {
|
||
btn.classList.toggle("active", btn.dataset.activityFilter === activityFilter);
|
||
});
|
||
}
|
||
|
||
function renderActivity(entries = recentActivityEntries) {
|
||
recentActivityEntries = entries || [];
|
||
const out = document.getElementById("activity-results");
|
||
if (!recentActivityEntries.length) {
|
||
out.innerHTML = `<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 = "";
|
||
});
|
||
|
||
// ---------- duplicate review ----------
|
||
|
||
let lastDupeReview = null;
|
||
|
||
function dupePath(row) {
|
||
return row?.full_path || row?.path || row?.jav_id || "?";
|
||
}
|
||
|
||
function _groupFmtKey(keep, deletions) {
|
||
const all = [keep, ...deletions];
|
||
const exts = new Set(all.map(f => (f.path || f.full_path || "").split(".").pop().toLowerCase()).filter(e => e && e.length <= 4 && /^[a-z]+$/.test(e)));
|
||
if (exts.has("mkv") && exts.has("mp4") && !exts.has("wmv") && !exts.has("avi")) return "MKV/MP4";
|
||
if (exts.has("wmv") && exts.has("mp4") && !exts.has("mkv")) return "WMV/MP4";
|
||
if (exts.has("avi") && exts.has("mp4") && !exts.has("mkv")) return "AVI/MP4";
|
||
if (exts.size === 1) return "Same format";
|
||
return null; // mixed/unusual — visible under All, no chip
|
||
}
|
||
|
||
function _pathRes(path) {
|
||
if (/\[2160p\]/i.test(path) || /\b4[kK]\b/.test(path)) return 2160;
|
||
if (/\[1080p\]/i.test(path)) return 1080;
|
||
if (/\[720p\]/i.test(path)) return 720;
|
||
if (/\[480p\]/i.test(path)) return 480;
|
||
return 0;
|
||
}
|
||
|
||
function _groupResKey(keep, deletions) {
|
||
const keepRes = _pathRes(dupePath(keep));
|
||
const maxDelRes = deletions.reduce((m, d) => Math.max(m, _pathRes(dupePath(d))), 0);
|
||
if (keepRes === 0 && maxDelRes === 0) return "unknown";
|
||
if (keepRes === maxDelRes) return "same";
|
||
return keepRes > maxDelRes ? "upgrade" : "downgrade";
|
||
}
|
||
|
||
let _drActiveFmt = "all";
|
||
let _drActiveRes = "all";
|
||
let _drActiveStatus = "all";
|
||
let _drActiveParts = "all";
|
||
let _drActiveVip = "all";
|
||
let _drActiveSearch = "";
|
||
|
||
function _drPromoteToKeep(row) {
|
||
row.classList.remove("del", "confirmed", "unconfirmed", "queued");
|
||
row.classList.add("keep");
|
||
const tag = row.querySelector(".dr-tag");
|
||
tag.textContent = "KEEP";
|
||
tag.className = "dr-tag keep";
|
||
}
|
||
|
||
function _drDemoteToDelete(row) {
|
||
row.classList.remove("keep", "queued");
|
||
row.classList.add("del", "confirmed");
|
||
const tag = row.querySelector(".dr-tag");
|
||
tag.textContent = "DELETE?";
|
||
tag.className = "dr-tag del";
|
||
}
|
||
|
||
function _drApplyFilters() {
|
||
const wraps = document.querySelectorAll("#dupe-review-modal-body .dr-card-wrap");
|
||
for (const wrap of wraps) {
|
||
const fmtMatch = _drActiveFmt === "all" || wrap.dataset.fmt === _drActiveFmt;
|
||
const resMatch = _drActiveRes === "all" || wrap.dataset.res === _drActiveRes;
|
||
let statusMatch = true;
|
||
if (_drActiveStatus !== "all") {
|
||
const skipped = wrap.classList.contains("skipped");
|
||
const delRows = wrap.querySelectorAll(".dr-row.del");
|
||
const doneRows = wrap.querySelectorAll(".dr-row.del.done");
|
||
const allDone = delRows.length > 0 && doneRows.length === delRows.length;
|
||
if (_drActiveStatus === "skipped") statusMatch = skipped;
|
||
else if (_drActiveStatus === "done") statusMatch = !skipped && allDone;
|
||
else statusMatch = !skipped && !allDone; // pending
|
||
}
|
||
const partsMatch = _drActiveParts === "all" || wrap.dataset.parts === "1";
|
||
const vipMatch = _drActiveVip === "all" || wrap.dataset.vip === "1";
|
||
const q = _drActiveSearch;
|
||
const searchMatch = !q
|
||
|| (wrap.querySelector(".dr-card-id")?.textContent.toLowerCase().includes(q))
|
||
|| ([...wrap.querySelectorAll(".dr-path")].some(p => p.textContent.toLowerCase().includes(q)));
|
||
wrap.classList.toggle("dr-hidden", !(fmtMatch && resMatch && statusMatch && partsMatch && vipMatch && searchMatch));
|
||
}
|
||
}
|
||
|
||
function _drBadges(keep, deletions, catalogs) {
|
||
const all = [keep, ...deletions, ...catalogs];
|
||
const paths = all.map((f) => dupePath(f));
|
||
const out = [];
|
||
if (paths.some((p) => /\[2160p\]/i.test(p) || /\b4k\b/i.test(p))) {
|
||
out.push(`<span class="dr-badge b4k">4K</span>`);
|
||
} else if (paths.some((p) => /\[1080p\]/i.test(p))) {
|
||
out.push(`<span class="dr-badge b1080">1080p</span>`);
|
||
}
|
||
if (paths.some((p) => /clearjav/i.test(p))) {
|
||
out.push(`<span class="dr-badge bcljav">CLEARJAV</span>`);
|
||
}
|
||
const exts = new Set(
|
||
all.map((f) => (f.path || f.full_path || "").split(".").pop().toLowerCase()).filter((e) => e && e.length <= 4)
|
||
);
|
||
if (exts.size > 1) {
|
||
out.push(`<span class="dr-badge bfmt">${escapeHtml([...exts].join("/").toUpperCase())}</span>`);
|
||
} else if (exts.has("mkv")) {
|
||
out.push(`<span class="dr-badge bmkv">MKV</span>`);
|
||
}
|
||
return out.join("");
|
||
}
|
||
|
||
function renderDupeReview(r) {
|
||
const out = document.getElementById("dupe-review-modal-body");
|
||
const summary = document.getElementById("dupe-review-results");
|
||
const exportBtn = document.getElementById("dupe-review-export");
|
||
if (!r || !r.ok) {
|
||
lastDupeReview = null;
|
||
exportBtn.disabled = true;
|
||
out.innerHTML = `<div class="dr-empty"><span style="color:#f87171;">Error:</span> ${escapeHtml(r?.error || "no response")}</div>`;
|
||
summary.innerHTML = out.innerHTML;
|
||
openModal("dupe-review-modal");
|
||
return;
|
||
}
|
||
lastDupeReview = r;
|
||
exportBtn.disabled = false;
|
||
_drActiveFmt = "all";
|
||
_drActiveRes = "all";
|
||
_drActiveStatus = "all";
|
||
_drActiveParts = "all";
|
||
_drActiveVip = "all";
|
||
_drActiveSearch = "";
|
||
|
||
const groups = Object.entries(r.groups || {});
|
||
const totalCandidates = groups.reduce((n, [, g]) => n + (g.delete_candidates?.length || 0), 0);
|
||
const roots = [
|
||
...(r.roots?.source || []).map((root) => `source: ${root}`),
|
||
...(r.roots?.target || []).map((root) => `target: ${root}`),
|
||
];
|
||
|
||
// Compute per-group fmt/res keys and counts for filter bar
|
||
const fmtCounts = {};
|
||
const resCounts = {};
|
||
let partsCount = 0;
|
||
let vipCount = 0;
|
||
let riskCount = 0;
|
||
for (const [javId, g] of groups) {
|
||
const fk = _groupFmtKey(g.keep || {}, g.delete_candidates || []);
|
||
const rk = _groupResKey(g.keep || {}, g.delete_candidates || []);
|
||
fmtCounts[fk] = (fmtCounts[fk] || 0) + 1;
|
||
resCounts[rk] = (resCounts[rk] || 0) + 1;
|
||
if (javId.includes("#part")) partsCount++;
|
||
if ([g.keep, ...(g.delete_candidates || [])].some((row) => /(?:^|[\\/])clearjav(?:[\\/]|$)/i.test(dupePath(row)))) vipCount++;
|
||
if ((g.risks || []).length) riskCount++;
|
||
}
|
||
|
||
const parts = [];
|
||
|
||
// Filter bar (sticky top)
|
||
if (groups.length) {
|
||
const fmtOrder = ["MKV/MP4", "WMV/MP4", "AVI/MP4", "Same format"];
|
||
const resOrder = [
|
||
{ key: "same", label: "Same res" },
|
||
{ key: "upgrade", label: "Upgrade" },
|
||
];
|
||
const fmtChips = fmtOrder
|
||
.filter(k => fmtCounts[k])
|
||
.map(k => `<button class="dr-chip" data-ftype="fmt" data-fval="${escapeHtml(k)}">${escapeHtml(k)} (${fmtCounts[k]})</button>`)
|
||
.join("");
|
||
const resChips = resOrder
|
||
.filter(({ key }) => resCounts[key])
|
||
.map(({ key, label }) => `<button class="dr-chip" data-ftype="res" data-fval="${key}">${escapeHtml(label)} (${resCounts[key]})</button>`)
|
||
.join("");
|
||
const totalGroups = groups.length;
|
||
parts.push(`<div class="dr-filter-bar">
|
||
<input id="dr-search" class="dr-search" type="text" placeholder="Search ID or path…" autocomplete="off" spellcheck="false">
|
||
<span class="dr-filter-sep"></span>
|
||
<span class="dr-filter-label">Format:</span>
|
||
<button class="dr-chip active" data-ftype="fmt" data-fval="all">All</button>
|
||
${fmtChips}
|
||
${resChips.length ? `<span class="dr-filter-sep"></span><span class="dr-filter-label">Resolution:</span><button class="dr-chip active" data-ftype="res" data-fval="all">All</button>${resChips}` : ""}
|
||
<span class="dr-filter-sep"></span>
|
||
<span class="dr-filter-label">Status:</span>
|
||
<button class="dr-chip active" data-ftype="status" data-fval="all">All</button>
|
||
<button class="dr-chip" data-ftype="status" data-fval="pending">Pending (${totalGroups - riskCount})</button>
|
||
<button class="dr-chip" data-ftype="status" data-fval="done">Done (0)</button>
|
||
<button class="dr-chip" data-ftype="status" data-fval="skipped">Skipped (${riskCount})</button>
|
||
${vipCount ? `<span class="dr-filter-sep"></span><button class="dr-chip active" data-ftype="vip" data-fval="all">All</button><button class="dr-chip" data-ftype="vip" data-fval="only">ClearJAV (${vipCount})</button>` : ""}
|
||
${partsCount ? `<span class="dr-filter-sep"></span><button class="dr-chip active" data-ftype="parts" data-fval="all">All</button><button class="dr-chip" data-ftype="parts" data-fval="only">Parts (${partsCount})</button>` : ""}
|
||
</div>`);
|
||
}
|
||
|
||
// Stats bar
|
||
parts.push(`<div class="dr-stats">
|
||
<div class="dr-stat"><div class="val red">${escapeHtml(r.potential_reclaim_human || "0 B")}</div><div class="key">Recoverable</div></div>
|
||
<div class="dr-stat"><div class="val">${escapeHtml(String(r.group_count || 0))}</div><div class="key">Duplicate Groups</div></div>
|
||
<div class="dr-stat"><div class="val blue">${escapeHtml(String(totalCandidates))}</div><div class="key">Delete Candidates</div></div>
|
||
</div>`);
|
||
if (riskCount) {
|
||
parts.push(`<div class="dr-roots" style="color:#ffe487;">${escapeHtml(String(riskCount))} risky group${riskCount !== 1 ? "s" : ""} are skipped by default. Review part-like filenames before adding them back to the delete queue.</div>`);
|
||
}
|
||
|
||
// Roots hint
|
||
if (roots.length) {
|
||
parts.push(`<div class="dr-roots">${escapeHtml(roots.join(" · "))}</div>`);
|
||
}
|
||
|
||
// Group cards
|
||
if (!groups.length) {
|
||
parts.push(`<div class="dr-empty">No cached duplicate groups found.</div>`);
|
||
} else {
|
||
const cards = [];
|
||
for (const [javId, group] of groups) {
|
||
const keep = group.keep || {};
|
||
const deletions = group.delete_candidates || [];
|
||
const catalogs = group.catalog || [];
|
||
const reclaim = deletions.reduce((s, e) => s + (e.size || 0), 0);
|
||
const reclaimHuman = deletions.length && deletions[0].size_human
|
||
? deletions.map((d) => d.size_human).join(" + ")
|
||
: "";
|
||
const reclaimLabel = reclaimHuman
|
||
? `<span class="dr-card-reclaim">−${escapeHtml(reclaimHuman)}</span>`
|
||
: "";
|
||
|
||
const fmtKey = _groupFmtKey(keep, deletions);
|
||
const resKey = _groupResKey(keep, deletions);
|
||
const risks = group.risks || [];
|
||
const keepReason = group.keep_reason?.summary || "";
|
||
|
||
const rows = [];
|
||
if (risks.length) {
|
||
rows.push(`<div class="dr-risk-note"><strong>Review before deleting:</strong> ${risks.map((risk) => escapeHtml(risk.summary || "multipart risk")).join("<br>")}</div>`);
|
||
}
|
||
rows.push(`<div class="dr-row keep" data-full-path="${escapeHtml(dupePath(keep))}">
|
||
<span class="dr-tag keep">KEEP</span>
|
||
<span class="dr-path" title="${escapeHtml(dupePath(keep))}">${escapeHtml(dupePath(keep))}</span>
|
||
${keep.size_human ? `<span class="dr-sz keep">${escapeHtml(keep.size_human)}</span>` : ""}
|
||
</div>`);
|
||
if (keepReason) {
|
||
rows.push(`<div class="dr-keep-reason">Suggested KEEP reason: ${escapeHtml(keepReason)}</div>`);
|
||
}
|
||
for (const d of deletions) {
|
||
rows.push(`<div class="dr-row del confirmed" data-full-path="${escapeHtml(dupePath(d))}">
|
||
<span class="dr-tag del">DELETE?</span>
|
||
<span class="dr-path" title="${escapeHtml(dupePath(d))}">${escapeHtml(dupePath(d))}</span>
|
||
${d.size_human ? `<span class="dr-sz del">${escapeHtml(d.size_human)}</span>` : ""}
|
||
</div>`);
|
||
}
|
||
for (const c of catalogs) {
|
||
rows.push(`<div class="dr-row cat">
|
||
<span class="dr-tag cat">CATALOG</span>
|
||
<span class="dr-path" title="${escapeHtml(dupePath(c))}">${escapeHtml(dupePath(c))}</span>
|
||
${c.size_human ? `<span class="dr-sz cat">${escapeHtml(c.size_human)}</span>` : ""}
|
||
</div>`);
|
||
}
|
||
|
||
const hasClearJav = [keep, ...deletions].some((row) => /(?:^|[\\/])clearjav(?:[\\/]|$)/i.test(dupePath(row)));
|
||
cards.push(`<div class="dr-card-wrap${risks.length ? " skipped dr-risk" : ""}" data-fmt="${escapeHtml(fmtKey)}" data-res="${escapeHtml(resKey)}" data-parts="${javId.includes("#part") ? "1" : "0"}" data-vip="${hasClearJav ? "1" : "0"}" data-risk="${risks.length ? "1" : "0"}">
|
||
<div class="dr-card">
|
||
<div class="dr-card-head">
|
||
<span class="dr-card-id">${escapeHtml(javId)}</span>
|
||
${_drBadges(keep, deletions, catalogs)}
|
||
${reclaimLabel}
|
||
</div>
|
||
<div class="dr-card-body">${rows.join("")}</div>
|
||
</div>
|
||
<button class="dr-skip-ear" title="${risks.length ? "Risk flagged - click to include after review" : "Skip - decide later"}"><span>${risks.length ? "Review" : "Skip"}</span></button>
|
||
</div>`);
|
||
}
|
||
parts.push(`<div class="dr-body">${cards.join("")}</div>`);
|
||
}
|
||
|
||
|
||
// Variant alerts — bare ID + variant coexist (e.g. IBW-902 and IBW-902z both present)
|
||
const variantAlerts = r.variant_alerts || [];
|
||
if (variantAlerts.length) {
|
||
const alertCards = variantAlerts.map((alert) => {
|
||
const rows = (alert.files || []).map((f) => {
|
||
const detectedId = f.detected_id || f.jav_id || "";
|
||
const isVariant = detectedId !== alert.bare_id;
|
||
const tag = isVariant
|
||
? `<span class="dr-tag variant">${escapeHtml(detectedId)}</span>`
|
||
: `<span class="dr-tag bare">BARE</span>`;
|
||
return `<div class="dr-row variant">
|
||
${tag}
|
||
<span class="dr-path" title="${escapeHtml(dupePath(f))}">${escapeHtml(dupePath(f))}</span>
|
||
${f.size_human ? `<span class="dr-sz">${escapeHtml(f.size_human)}</span>` : ""}
|
||
</div>`;
|
||
}).join("");
|
||
return `<div class="dr-card variant-alert">
|
||
<div class="dr-card-head">
|
||
<span class="dr-card-id">${escapeHtml(alert.bare_id)}</span>
|
||
<span class="dr-variant-label">⚠ variant — manual review</span>
|
||
</div>
|
||
<div class="dr-card-body">${rows}</div>
|
||
</div>`;
|
||
}).join("");
|
||
parts.push(`<div class="dr-variant-section">
|
||
<div class="dr-variant-heading">⚠ ${variantAlerts.length} Variant Alert${variantAlerts.length !== 1 ? "s" : ""} — Same base ID, different product designator</div>
|
||
<div class="dr-body">${alertCards}</div>
|
||
</div>`);
|
||
}
|
||
|
||
if ((r.skipped || []).length) {
|
||
const samples = (r.skipped || []).slice(0, 5)
|
||
.map((s) => `<div class="dr-skipped-item">${escapeHtml(s.name || s.path || "?")} · ${escapeHtml(s.reason || "unparsed ID")}</div>`)
|
||
.join("");
|
||
parts.push(`<div class="dr-skipped">Skipped ${escapeHtml(String(r.skipped.length))} path(s) with no parseable ID${samples ? ":" : "."}</div>${samples}`);
|
||
}
|
||
|
||
out.innerHTML = parts.join("");
|
||
summary.textContent = `${r.group_count || 0} cached duplicate group(s) reviewed. Results are open in the review window.`;
|
||
openModal("dupe-review-modal");
|
||
}
|
||
|
||
function _drUpdateExecuteBtn() {
|
||
const confirmed = document.querySelectorAll("#dupe-review-modal-body .dr-card-wrap:not(.skipped):not(.dr-hidden) .dr-row.del.confirmed:not(.done)");
|
||
const btn = document.getElementById("dupe-review-execute");
|
||
const status = document.getElementById("dupe-review-confirm-status");
|
||
const n = confirmed.length;
|
||
btn.textContent = `Execute Deletions (${n})`;
|
||
btn.disabled = n === 0;
|
||
status.textContent = n > 0 ? `${n} file${n !== 1 ? "s" : ""} queued for deletion — click to execute` : "";
|
||
}
|
||
|
||
// Search input
|
||
document.getElementById("dupe-review-modal-body").addEventListener("input", (e) => {
|
||
if (e.target.id !== "dr-search") return;
|
||
_drActiveSearch = e.target.value.trim().toLowerCase();
|
||
_drApplyFilters();
|
||
_drUpdateExecuteBtn();
|
||
});
|
||
|
||
// Filter chips + toggle DELETE? rows on click
|
||
document.getElementById("dupe-review-modal-body").addEventListener("click", (e) => {
|
||
// Filter chip
|
||
const chip = e.target.closest(".dr-chip");
|
||
if (chip) {
|
||
const ftype = chip.dataset.ftype;
|
||
const fval = chip.dataset.fval;
|
||
if (ftype === "fmt") {
|
||
_drActiveFmt = fval;
|
||
} else if (ftype === "res") {
|
||
_drActiveRes = fval;
|
||
} else if (ftype === "status") {
|
||
_drActiveStatus = fval;
|
||
} else if (ftype === "parts") {
|
||
_drActiveParts = fval;
|
||
} else if (ftype === "vip") {
|
||
_drActiveVip = fval;
|
||
}
|
||
document.querySelectorAll(`#dupe-review-modal-body .dr-chip[data-ftype='${ftype}']`).forEach(c => {
|
||
c.classList.toggle("active", c.dataset.fval === fval);
|
||
});
|
||
_drApplyFilters();
|
||
_drUpdateExecuteBtn();
|
||
return;
|
||
}
|
||
|
||
// Search input
|
||
if (e.target.id === "dr-search") return; // handled via input event below
|
||
|
||
// Skip ear — toggle skipped on the wrap
|
||
const ear = e.target.closest(".dr-skip-ear");
|
||
if (ear) {
|
||
ear.closest(".dr-card-wrap").classList.toggle("skipped");
|
||
_drUpdateExecuteBtn();
|
||
return;
|
||
}
|
||
|
||
// Click KEEP row → full swap: this becomes DELETE?, pick a replacement KEEP
|
||
const keepRow = e.target.closest(".dr-row.keep");
|
||
if (keepRow && !keepRow.classList.contains("done")) {
|
||
const card = keepRow.closest(".dr-card");
|
||
// Prefer an unconfirmed del row as new KEEP (least disruptive), else first any del row
|
||
const newKeep = card.querySelector(".dr-row.del.unconfirmed:not(.done)")
|
||
|| card.querySelector(".dr-row.del:not(.done)");
|
||
if (!newKeep) return;
|
||
_drPromoteToKeep(newKeep);
|
||
_drDemoteToDelete(keepRow);
|
||
_drUpdateExecuteBtn();
|
||
return;
|
||
}
|
||
|
||
// DELETE? rows are not clickable — click the KEEP row to swap
|
||
});
|
||
|
||
document.getElementById("dupe-review-execute").addEventListener("click", async () => {
|
||
const rows = [...document.querySelectorAll("#dupe-review-modal-body .dr-card-wrap:not(.skipped):not(.dr-hidden) .dr-row.del.confirmed:not(.done)")];
|
||
if (!rows.length) return;
|
||
const deleteRows = rows.filter((row) => row.dataset.fullPath);
|
||
if (!deleteRows.length) return;
|
||
const btn = document.getElementById("dupe-review-execute");
|
||
const status = document.getElementById("dupe-review-confirm-status");
|
||
const total = deleteRows.length;
|
||
btn.disabled = true;
|
||
let done = 0, failed = 0;
|
||
for (const [index, row] of deleteRows.entries()) {
|
||
const path = row.dataset.fullPath;
|
||
status.textContent = `Deleting ${index + 1}/${total}...`;
|
||
const res = await chrome.runtime.sendMessage({ type: "delete_batch", paths: [path] });
|
||
if (!res?.ok && res?.error === "deletion is disabled in options") {
|
||
status.textContent = "Deletion is disabled - enable it in the Deletion tab first.";
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
const r = (res?.results || [])[0] || { ok: false, error: res?.error || "delete failed" };
|
||
const tag = row.querySelector(".dr-tag");
|
||
if (r.ok) {
|
||
row.classList.remove("confirmed");
|
||
row.classList.add("done");
|
||
tag.textContent = "DELETED";
|
||
done++;
|
||
} else {
|
||
row.classList.add("error");
|
||
tag.textContent = "ERROR";
|
||
row.title = r.error || "delete failed";
|
||
failed++;
|
||
}
|
||
}
|
||
|
||
const parts = [];
|
||
if (done) parts.push(`${done} deleted`);
|
||
if (failed) parts.push(`${failed} failed`);
|
||
status.textContent = parts.join(" · ") || "Nothing processed.";
|
||
_drUpdateExecuteBtn();
|
||
});
|
||
|
||
document.getElementById("dupe-review-run").addEventListener("click", async () => {
|
||
const out = document.getElementById("dupe-review-modal-body");
|
||
const executeBtn = document.getElementById("dupe-review-execute");
|
||
out.textContent = "reviewing cached duplicate groups...";
|
||
executeBtn.disabled = true;
|
||
executeBtn.textContent = "Execute Deletions (0)";
|
||
document.getElementById("dupe-review-confirm-status").textContent = "";
|
||
openModal("dupe-review-modal");
|
||
renderDupeReview(await chrome.runtime.sendMessage({ type: "dupe-review" }));
|
||
_drUpdateExecuteBtn();
|
||
});
|
||
|
||
document.getElementById("dupe-review-export").addEventListener("click", () => {
|
||
if (!lastDupeReview) return;
|
||
const blob = new Blob([JSON.stringify(lastDupeReview, null, 2)], { type: "application/json" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||
a.href = url;
|
||
a.download = `rclone-jav-dupe-review-${stamp}.json`;
|
||
a.click();
|
||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||
});
|
||
for (const id of ["dupe-review-modal-close", "dupe-review-modal-done"]) {
|
||
document.getElementById(id).addEventListener("click", () => closeModal("dupe-review-modal"));
|
||
}
|
||
document.getElementById("dupe-review-modal").addEventListener("click", (event) => {
|
||
if (event.target.id === "dupe-review-modal") closeModal("dupe-review-modal");
|
||
});
|
||
|
||
// ---- Keep Ranking ----
|
||
|
||
const KR_DEFAULT_FMTS = ["mkv", "mp4", "wmv", "avi"];
|
||
const KR_DEFAULT_VIP_FOLDERS = ["ClearJAV"];
|
||
|
||
function _krWireDraggableList(list) {
|
||
if (!list) return;
|
||
let dragSrc = null;
|
||
for (const item of list.querySelectorAll(".kr-fmt-item")) {
|
||
item.addEventListener("dragstart", (e) => {
|
||
dragSrc = item;
|
||
item.classList.add("dragging");
|
||
e.dataTransfer.effectAllowed = "move";
|
||
});
|
||
item.addEventListener("dragend", () => {
|
||
item.classList.remove("dragging");
|
||
list.querySelectorAll(".kr-fmt-item").forEach(i => i.classList.remove("drag-over"));
|
||
list.querySelectorAll(".kr-fmt-item").forEach((el, idx) => {
|
||
const pr = el.querySelector(".kr-fmt-priority");
|
||
if (pr) pr.textContent = `#${idx + 1}`;
|
||
});
|
||
});
|
||
item.addEventListener("dragover", (e) => {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "move";
|
||
if (item !== dragSrc) item.classList.add("drag-over");
|
||
});
|
||
item.addEventListener("dragleave", () => item.classList.remove("drag-over"));
|
||
item.addEventListener("drop", (e) => {
|
||
e.preventDefault();
|
||
item.classList.remove("drag-over");
|
||
if (dragSrc && dragSrc !== item) {
|
||
const items = [...list.querySelectorAll(".kr-fmt-item")];
|
||
const srcIdx = items.indexOf(dragSrc);
|
||
const dstIdx = items.indexOf(item);
|
||
if (srcIdx < dstIdx) list.insertBefore(dragSrc, item.nextSibling);
|
||
else list.insertBefore(dragSrc, item);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function _krRenderFmtList(fmts) {
|
||
const list = document.getElementById("kr-fmt-list");
|
||
if (!list) return;
|
||
list.innerHTML = fmts.map((fmt, i) =>
|
||
`<div class="kr-fmt-item" draggable="true" data-fmt="${escapeHtml(fmt)}">
|
||
<span class="kr-fmt-grip">⠿</span>
|
||
<span>${escapeHtml(fmt)}</span>
|
||
<span class="kr-fmt-priority">#${i + 1}</span>
|
||
</div>`
|
||
).join("");
|
||
_krWireDraggableList(list);
|
||
}
|
||
|
||
function _krGetCurrentFmts() {
|
||
return [...document.querySelectorAll("#kr-fmt-list .kr-fmt-item")]
|
||
.map(el => el.dataset.fmt);
|
||
}
|
||
|
||
function _krRenderVipList(folders) {
|
||
const list = document.getElementById("kr-vip-list");
|
||
if (!list) return;
|
||
list.innerHTML = (folders || []).map((folder, i) =>
|
||
`<div class="kr-fmt-item" draggable="true" data-folder="${escapeHtml(folder)}">
|
||
<span class="kr-fmt-grip">⠿</span>
|
||
<span>${escapeHtml(folder)}</span>
|
||
<button class="kr-vip-remove" type="button" title="Remove VIP folder">x</button>
|
||
<span class="kr-fmt-priority">#${i + 1}</span>
|
||
</div>`
|
||
).join("");
|
||
for (const btn of list.querySelectorAll(".kr-vip-remove")) {
|
||
btn.addEventListener("click", () => {
|
||
btn.closest(".kr-fmt-item")?.remove();
|
||
list.querySelectorAll(".kr-fmt-item").forEach((el, idx) => {
|
||
const pr = el.querySelector(".kr-fmt-priority");
|
||
if (pr) pr.textContent = `#${idx + 1}`;
|
||
});
|
||
});
|
||
}
|
||
_krWireDraggableList(list);
|
||
}
|
||
|
||
function _krGetVipFolders() {
|
||
return [...document.querySelectorAll("#kr-vip-list .kr-fmt-item")]
|
||
.map((el) => el.dataset.folder)
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function _krAddVipFolder() {
|
||
const input = document.getElementById("kr-vip-add");
|
||
const folder = input?.value.trim();
|
||
if (!folder) return;
|
||
const current = _krGetVipFolders();
|
||
if (!current.some((item) => item.toLowerCase() === folder.toLowerCase())) {
|
||
_krRenderVipList([...current, folder]);
|
||
}
|
||
input.value = "";
|
||
}
|
||
|
||
document.getElementById("kr-vip-add-btn")?.addEventListener("click", _krAddVipFolder);
|
||
document.getElementById("kr-vip-add")?.addEventListener("keydown", (event) => {
|
||
if (event.key === "Enter") {
|
||
event.preventDefault();
|
||
_krAddVipFolder();
|
||
}
|
||
});
|
||
|
||
async function loadKeepRanking() {
|
||
try {
|
||
const r = await chrome.runtime.sendMessage({ type: "get-keep-ranking" });
|
||
if (!r || !r.ok) return;
|
||
const ranking = r.keep_ranking || {};
|
||
const toleranceEl = document.getElementById("kr-tolerance");
|
||
const resTagEl = document.getElementById("kr-res-tag");
|
||
const longerNameEl = document.getElementById("kr-longer-name");
|
||
if (toleranceEl) toleranceEl.value = ranking.size_tolerance_mib ?? 0;
|
||
if (resTagEl) resTagEl.checked = ranking.tiebreak_res_tag !== false;
|
||
if (longerNameEl) longerNameEl.checked = ranking.tiebreak_longer_name !== false;
|
||
_krRenderVipList(ranking.priority_folders || KR_DEFAULT_VIP_FOLDERS);
|
||
_krRenderFmtList(ranking.format_preference || KR_DEFAULT_FMTS);
|
||
} catch (e) {
|
||
// non-fatal — panel just shows defaults
|
||
_krRenderVipList(KR_DEFAULT_VIP_FOLDERS);
|
||
_krRenderFmtList(KR_DEFAULT_FMTS);
|
||
}
|
||
}
|
||
|
||
document.getElementById("kr-save")?.addEventListener("click", async () => {
|
||
const status = document.getElementById("kr-save-status");
|
||
const toleranceEl = document.getElementById("kr-tolerance");
|
||
const resTagEl = document.getElementById("kr-res-tag");
|
||
const longerNameEl = document.getElementById("kr-longer-name");
|
||
const tolerance = parseFloat(toleranceEl?.value ?? "0");
|
||
if (isNaN(tolerance) || tolerance < 0) {
|
||
status.textContent = "Size tolerance must be 0 or a positive number.";
|
||
status.className = "kr-save-status err";
|
||
return;
|
||
}
|
||
const ranking = {
|
||
priority_folders: _krGetVipFolders(),
|
||
size_tolerance_mib: tolerance,
|
||
format_preference: _krGetCurrentFmts(),
|
||
tiebreak_res_tag: resTagEl?.checked !== false,
|
||
tiebreak_longer_name: longerNameEl?.checked !== false,
|
||
};
|
||
status.textContent = "Saving…";
|
||
status.className = "kr-save-status";
|
||
try {
|
||
const r = await chrome.runtime.sendMessage({ type: "save-keep-ranking", keep_ranking: ranking });
|
||
if (r?.ok) {
|
||
status.textContent = "Saved — next dupe review will use the updated ranking.";
|
||
status.className = "kr-save-status ok";
|
||
} else {
|
||
status.textContent = "Error: " + (r?.error || "unknown");
|
||
status.className = "kr-save-status err";
|
||
}
|
||
} catch (e) {
|
||
status.textContent = "Error: " + e.message;
|
||
status.className = "kr-save-status err";
|
||
}
|
||
});
|
||
|
||
// Load on page open
|
||
loadKeepRanking();
|
||
|
||
// ---- Library Issues ----
|
||
|
||
let lastLibraryIssues = null;
|
||
let _libraryIssuesDirty = false;
|
||
|
||
function renderLibraryIssues(r) {
|
||
const out = document.getElementById("library-issues-modal-body");
|
||
const statusEl = document.getElementById("library-issues-results");
|
||
const renameAllBtn = document.getElementById("library-issues-rename-all");
|
||
const renameStatus = document.getElementById("library-issues-rename-status");
|
||
|
||
if (!r || !r.ok) {
|
||
lastLibraryIssues = null;
|
||
renameAllBtn.disabled = true;
|
||
out.innerHTML = `<div class="li-empty" style="color:#f87171;">Error: ${escapeHtml(r?.error || "no response")}</div>`;
|
||
openModal("library-issues-modal");
|
||
return;
|
||
}
|
||
lastLibraryIssues = r;
|
||
|
||
const brackets = r.bracket_names || [];
|
||
const nohyphens = r.nohyphen_names || [];
|
||
const total = brackets.length + nohyphens.length;
|
||
|
||
renameAllBtn.disabled = total === 0;
|
||
renameStatus.textContent = "";
|
||
|
||
const parts = [];
|
||
if (!total) {
|
||
parts.push(`<div class="li-empty">✓ No library issues found. All filenames are canonical.</div>`);
|
||
} else {
|
||
parts.push(`<div class="li-stats"><b>${total}</b> file${total !== 1 ? "s" : ""} with non-canonical names — <b>${brackets.length}</b> bracket-wrapped, <b>${nohyphens.length}</b> no-hyphen</div>`);
|
||
|
||
const makeRow = (entry, tagClass, tagLabel) => {
|
||
const fname = entry.path.split("/").pop();
|
||
const dir = entry.path.lastIndexOf("/") !== -1 ? entry.path.slice(0, entry.path.lastIndexOf("/") + 1) : "";
|
||
return `<div class="li-row" data-remote="${escapeHtml(entry.remote)}" data-old="${escapeHtml(entry.path)}" data-new="${escapeHtml(dir + entry.canonical_name)}">
|
||
<span class="li-tag ${tagClass}">${tagLabel}</span>
|
||
<div class="li-names">
|
||
<span class="li-old" title="${escapeHtml(entry.path)}">${escapeHtml(fname)}</span>
|
||
<span class="li-new" title="${escapeHtml(entry.canonical_name)}">→ ${escapeHtml(entry.canonical_name)}</span>
|
||
</div>
|
||
<span class="li-sz">${escapeHtml(entry.size_human || "")}</span>
|
||
<button class="li-rename-btn" type="button">Rename</button>
|
||
</div>`;
|
||
};
|
||
|
||
if (brackets.length) {
|
||
parts.push(`<div class="li-section-head">Bracket-wrapped IDs (${brackets.length})</div>`);
|
||
parts.push(brackets.map((e) => makeRow(e, "bracket", "[ ]")).join(""));
|
||
}
|
||
if (nohyphens.length) {
|
||
parts.push(`<div class="li-section-head">No-hyphen IDs (${nohyphens.length})</div>`);
|
||
parts.push(nohyphens.map((e) => makeRow(e, "nohyphen", "no hyphen")).join(""));
|
||
}
|
||
}
|
||
|
||
out.innerHTML = parts.join("");
|
||
statusEl.textContent = total
|
||
? `${total} library issue(s) found. Review window is open.`
|
||
: "No library issues found.";
|
||
openModal("library-issues-modal");
|
||
|
||
// Per-row rename buttons
|
||
out.querySelectorAll(".li-rename-btn").forEach((btn) => {
|
||
btn.addEventListener("click", async () => {
|
||
const row = btn.closest(".li-row");
|
||
const remote = row.dataset.remote;
|
||
const oldPath = row.dataset.old;
|
||
const newPath = row.dataset.new;
|
||
btn.disabled = true;
|
||
btn.textContent = "…";
|
||
const res = await chrome.runtime.sendMessage({
|
||
type: "rename_file", remote, old_path: oldPath, new_path: newPath,
|
||
});
|
||
const tag = row.querySelector(".li-tag");
|
||
if (res?.ok) {
|
||
tag.className = "li-tag done";
|
||
tag.textContent = "✓";
|
||
btn.textContent = "Done";
|
||
row.querySelector(".li-old").style.textDecoration = "line-through";
|
||
_libraryIssuesDirty = true;
|
||
} else if (res?.conflict) {
|
||
tag.className = "li-tag conflict";
|
||
tag.textContent = "conflict";
|
||
btn.textContent = "Skip";
|
||
renameStatus.textContent = `Conflict: ${res.error || "target exists"}`;
|
||
} else {
|
||
tag.className = "li-tag conflict";
|
||
tag.textContent = "error";
|
||
btn.textContent = "Error";
|
||
renameStatus.textContent = res?.error || "rename failed";
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
document.getElementById("library-issues-run").addEventListener("click", async () => {
|
||
const out = document.getElementById("library-issues-modal-body");
|
||
out.innerHTML = `<div class="li-stats">Loading library issues from cache…</div>`;
|
||
openModal("library-issues-modal");
|
||
renderLibraryIssues(await chrome.runtime.sendMessage({ type: "library_issues" }));
|
||
});
|
||
|
||
document.getElementById("library-issues-rename-all").addEventListener("click", async () => {
|
||
const rows = [...document.querySelectorAll("#library-issues-modal-body .li-row")];
|
||
const renameStatus = document.getElementById("library-issues-rename-status");
|
||
const renameAllBtn = document.getElementById("library-issues-rename-all");
|
||
|
||
// Collect pending renames (skip already-done or disabled rows)
|
||
const pending = rows.reduce((acc, row) => {
|
||
const btn = row.querySelector(".li-rename-btn");
|
||
if (!btn || btn.disabled) return acc;
|
||
acc.push({ row, remote: row.dataset.remote, old_path: row.dataset.old, new_path: row.dataset.new });
|
||
return acc;
|
||
}, []);
|
||
|
||
if (!pending.length) return;
|
||
|
||
renameAllBtn.disabled = true;
|
||
renameStatus.textContent = `Renaming ${pending.length} file(s)…`;
|
||
|
||
const renames = pending.map(({ remote, old_path, new_path }) => ({ remote, old_path, new_path }));
|
||
const res = await chrome.runtime.sendMessage({ type: "rename_files_batch", renames });
|
||
|
||
const results = res?.results || [];
|
||
let done = 0, conflicts = 0, errors = 0;
|
||
|
||
results.forEach((r, i) => {
|
||
const { row } = pending[i];
|
||
const tag = row.querySelector(".li-tag");
|
||
const btn = row.querySelector(".li-rename-btn");
|
||
if (r.ok) {
|
||
tag.className = "li-tag done"; tag.textContent = "✓";
|
||
btn.disabled = true; btn.textContent = "Done";
|
||
row.querySelector(".li-old").style.textDecoration = "line-through";
|
||
done++;
|
||
} else if (r.conflict) {
|
||
tag.className = "li-tag conflict"; tag.textContent = "conflict";
|
||
btn.disabled = false; btn.textContent = "Skip";
|
||
conflicts++;
|
||
} else {
|
||
tag.className = "li-tag conflict"; tag.textContent = "error";
|
||
btn.disabled = false; btn.textContent = "Error";
|
||
errors++;
|
||
}
|
||
});
|
||
|
||
const parts = [];
|
||
if (done) parts.push(`${done} renamed`);
|
||
if (conflicts) parts.push(`${conflicts} conflict(s)`);
|
||
if (errors) parts.push(`${errors} error(s)`);
|
||
renameStatus.textContent = parts.join(" · ") || "Nothing to rename.";
|
||
renameAllBtn.disabled = false;
|
||
_libraryIssuesDirty = done > 0;
|
||
});
|
||
|
||
function _closeLibraryIssues() {
|
||
closeModal("library-issues-modal");
|
||
if (_libraryIssuesDirty) {
|
||
_libraryIssuesDirty = false;
|
||
chrome.runtime.sendMessage({ type: "library_issues" }, (r) => {
|
||
if (!r || !r.ok) return;
|
||
const total = (r.bracket_names?.length || 0) + (r.nohyphen_names?.length || 0);
|
||
document.getElementById("library-issues-results").textContent = total
|
||
? `${total} library issue(s) found. Review window is open.`
|
||
: "No library issues found.";
|
||
});
|
||
}
|
||
}
|
||
for (const id of ["library-issues-modal-close", "library-issues-modal-done"]) {
|
||
document.getElementById(id).addEventListener("click", _closeLibraryIssues);
|
||
}
|
||
document.getElementById("library-issues-modal").addEventListener("click", (e) => {
|
||
if (e.target.id === "library-issues-modal") _closeLibraryIssues();
|
||
});
|
||
|
||
(function () {
|
||
const rebuildBtn = document.getElementById("cache-rebuild-run");
|
||
const rebuildMode = document.getElementById("cache-rebuild-mode");
|
||
const cacheStatusOut = document.getElementById("cache-status-results");
|
||
const scanJobOut = document.getElementById("scan-job-results");
|
||
let _optScanTimer = null;
|
||
let _optScanning = false;
|
||
|
||
const _stopOptPoll = () => { if (_optScanTimer) { clearInterval(_optScanTimer); _optScanTimer = null; } };
|
||
|
||
function _setOptScanningState(scanning) {
|
||
_optScanning = scanning;
|
||
rebuildBtn.textContent = scanning ? "✕ Cancel" : "Rebuild Cache";
|
||
if (rebuildMode) rebuildMode.disabled = scanning;
|
||
rebuildBtn.style.background = scanning ? "#3a1a1a" : "";
|
||
rebuildBtn.style.borderColor = scanning ? "#722" : "";
|
||
rebuildBtn.style.color = scanning ? "#faa" : "";
|
||
}
|
||
|
||
function _scanStatus(r) {
|
||
if (!r || r.no_state) return "idle";
|
||
if (r.scanning && !r.done) return "running";
|
||
if (r.cancelled) return "cancelled";
|
||
if (r.scan_ok === false) return "failed";
|
||
if (r.done) return "completed";
|
||
return "idle";
|
||
}
|
||
|
||
function _renderScanJob(r) {
|
||
if (!r || r.no_state) {
|
||
scanJobOut.innerHTML = `<span style="color:#777;">no scan job recorded yet</span>`;
|
||
return;
|
||
}
|
||
const status = _scanStatus(r);
|
||
const pillCls = status === "completed" ? "ok" : status === "failed" ? "fail" : "";
|
||
const jobLabel = status === "running" ? "Current Scan Job" : "Last Scan Job";
|
||
const mode = r.scan_since ? `incremental ${r.scan_since}` : "full";
|
||
const scope = (r.scope && r.scope.length) ? r.scope.join(", ") : "configured scan roots";
|
||
const finished = r.finished_at || r.started_at || "";
|
||
const when = finished ? new Date(finished).toLocaleString() : "";
|
||
const elapsed = r.elapsed_s != null ? `${Number(r.elapsed_s).toFixed(1)}s` : "";
|
||
const count = r.file_count != null ? `${Number(r.file_count).toLocaleString()} files` : "";
|
||
const summary = [mode, scope, count, elapsed].filter(Boolean).join(" · ");
|
||
const jobs = (r.remote_jobs && r.remote_jobs.length)
|
||
? r.remote_jobs
|
||
: (r.remotes || []).map((remote, i) => ({
|
||
remote,
|
||
status: remote === r.current_remote ? status : i < (r.current_index || 0) ? "completed" : "queued",
|
||
files: remote === r.current_remote ? r.files_this_remote : null,
|
||
total: remote === r.current_remote ? r.files_remote_total : null,
|
||
}));
|
||
const jobRoots = jobs.map((j) => j.remote).filter(Boolean);
|
||
const retiredRoots = _configuredScanRoots.length
|
||
? jobRoots.filter((root) => !_configuredScanRoots.includes(root))
|
||
: [];
|
||
const jobRows = jobs.map((j) => {
|
||
const files = Number.isFinite(j.files) ? Number(j.files).toLocaleString() : "?";
|
||
const total = Number.isFinite(j.total) ? Number(j.total).toLocaleString() : "";
|
||
const pct = Number.isFinite(j.files) && Number.isFinite(j.total) && j.total > 0
|
||
? Math.min(100, Math.round((j.files / j.total) * 100)) : null;
|
||
const detail = [
|
||
j.label,
|
||
j.incremental ? "incremental" : "",
|
||
`${files}${total ? ` / ${total}` : ""} files`,
|
||
Number.isFinite(j.skipped) && j.skipped ? `${j.skipped} skipped` : "",
|
||
].filter(Boolean).join(" · ");
|
||
return `<div class="scan-remote">
|
||
<div><span style="color:#9dccff;">${escapeHtml(j.remote || "?")}</span> · <span>${escapeHtml(j.status || "queued")}</span></div>
|
||
<div style="color:#777;">${escapeHtml(detail)}</div>
|
||
${pct != null ? `<div class="scan-track"><div class="scan-fill" style="width:${pct}%"></div></div>` : ""}
|
||
</div>`;
|
||
}).join("");
|
||
scanJobOut.innerHTML = `
|
||
<div style="color:#888;margin-bottom:6px;">${escapeHtml(jobLabel)}${when ? ` · ${escapeHtml(when)}` : ""}</div>
|
||
<div class="scan-job-head"><span class="scan-pill ${pillCls}">${escapeHtml(status)}</span><span>${escapeHtml(summary || "scan job")}</span></div>
|
||
${retiredRoots.length ? `<div class="section-note warn" style="margin:0 0 8px;">Historical scan roots not in current config: ${escapeHtml(retiredRoots.join(", "))}. They are shown because this job was recorded before the scan roots changed.</div>` : ""}
|
||
${r.error ? `<div style="color:#faa;margin-bottom:6px;">${escapeHtml(r.error)}</div>` : ""}
|
||
${jobRows || `<div style="color:#777;">waiting for remote progress...</div>`}
|
||
`;
|
||
}
|
||
|
||
const _pollOptProgress = () => {
|
||
chrome.runtime.sendMessage({ type: "scan-progress" }, (r) => {
|
||
if (chrome.runtime.lastError || !r || !r.ok) return;
|
||
_renderScanJob(r);
|
||
if (r.done || !r.scanning) {
|
||
_stopOptPoll();
|
||
_setOptScanningState(false);
|
||
if (r.cancelled) {
|
||
return;
|
||
} else if (r.scan_ok !== false) {
|
||
setTimeout(() => document.getElementById("cache-status-run").click(), 500);
|
||
}
|
||
return;
|
||
}
|
||
});
|
||
};
|
||
|
||
async function _refreshScanJob() {
|
||
try {
|
||
const cache = await chrome.runtime.sendMessage({ type: "cache-status" });
|
||
if (cache && cache.ok) rememberConfiguredScanRoots(cache);
|
||
} catch {}
|
||
_pollOptProgress();
|
||
}
|
||
|
||
async function _startOptScan(scanRoots = [], forceSince = null) {
|
||
const out = scanJobOut;
|
||
if (_optScanning) {
|
||
// Cancel in-progress scan
|
||
rebuildBtn.disabled = true;
|
||
rebuildBtn.textContent = "Cancelling…";
|
||
chrome.runtime.sendMessage({ type: "scan-cancel" }, () => {
|
||
rebuildBtn.disabled = false;
|
||
// State will update on next poll tick
|
||
});
|
||
return;
|
||
}
|
||
// forceSince overrides dropdown (used by per-remote Refresh to stay incremental)
|
||
const scanSince = forceSince !== null ? forceSince : (rebuildMode ? rebuildMode.value : "");
|
||
const scope = scanRoots.length ? `refresh ${scanRoots.join(", ")}` : "all configured scan roots";
|
||
const label = scanSince ? `incrementally update files changed in the last ${scanSince}` : "fully rebuild";
|
||
const button = scanRoots.length ? "Refresh" : "Rebuild";
|
||
if (!confirm(`${button} cache now?\n\nScope: ${scope}\nMode: ${label}\n\nThis can take several minutes.`)) return;
|
||
cacheStatusOut.innerHTML = `<span style="color:#6ec1ff;">starting scan…</span>`;
|
||
out.innerHTML = "";
|
||
try {
|
||
const r = await chrome.runtime.sendMessage({ type: "run-scan", scanSince, scanRoots });
|
||
if (!r || !r.ok) {
|
||
out.innerHTML = `<span style="color:#faa;">scan failed:</span> ${escapeHtml(r?.error || "no response")}`;
|
||
return;
|
||
}
|
||
_setOptScanningState(true);
|
||
_pollOptProgress();
|
||
_optScanTimer = setInterval(_pollOptProgress, 1500);
|
||
} catch (err) {
|
||
out.innerHTML = `<span style="color:#faa;">scan failed:</span> ${escapeHtml(err.message || String(err))}`;
|
||
}
|
||
}
|
||
|
||
rebuildBtn.addEventListener("click", () => _startOptScan());
|
||
|
||
function _renderNonJavPanel(items, remote) {
|
||
const panel = document.createElement("div");
|
||
panel.className = "nonjav-panel";
|
||
panel.dataset.remote = remote;
|
||
const deleteEnabled = document.getElementById("enableDelete")?.checked;
|
||
const delBtnHtml = deleteEnabled
|
||
? `<button class="nonjav-del-all" type="button" title="Delete all non-JAV files in this remote">Delete All (${items.length})</button>`
|
||
: `<span style="font-size:11px;color:#555;">Enable deletion in settings to delete</span>`;
|
||
panel.innerHTML = `
|
||
<div class="nonjav-panel-head">
|
||
<span class="nonjav-panel-title">${escapeHtml(remote)} · ${items.length} non-JAV file${items.length !== 1 ? "s" : ""}</span>
|
||
${delBtnHtml}
|
||
</div>
|
||
<div class="nonjav-list">${items.map(f => `
|
||
<div class="nonjav-item" data-full-path="${escapeHtml(f.full_path)}">
|
||
<span class="nonjav-ext">${escapeHtml(f.ext || "?")}</span>
|
||
<span class="nonjav-path" title="${escapeHtml(f.full_path)}">${escapeHtml(f.path)}</span>
|
||
${deleteEnabled ? `<button class="nonjav-del-one" type="button">Delete</button>` : ""}
|
||
</div>`).join("")}
|
||
</div>
|
||
<div class="nonjav-status"></div>`;
|
||
// Delete one
|
||
panel.addEventListener("click", async (e) => {
|
||
const btn = e.target.closest(".nonjav-del-one");
|
||
if (btn) {
|
||
const item = btn.closest(".nonjav-item");
|
||
const path = item?.dataset.fullPath;
|
||
if (!path) return;
|
||
if (!confirm(`Delete?\n${path}`)) return;
|
||
btn.disabled = true;
|
||
const r = await chrome.runtime.sendMessage({ type: "delete-skipped", paths: [path] });
|
||
if (r?.ok) {
|
||
item.classList.add("deleted");
|
||
item.querySelector(".nonjav-del-one")?.remove();
|
||
_updateNonJavDelAll(panel);
|
||
} else {
|
||
btn.disabled = false;
|
||
panel.querySelector(".nonjav-status").textContent = "Error: " + (r?.error || "failed");
|
||
}
|
||
return;
|
||
}
|
||
const delAll = e.target.closest(".nonjav-del-all");
|
||
if (delAll) {
|
||
const allItems = [...panel.querySelectorAll(".nonjav-item:not(.deleted)")];
|
||
const paths = allItems.map(i => i.dataset.fullPath).filter(Boolean);
|
||
if (!paths.length) return;
|
||
if (!confirm(`Delete all ${paths.length} non-JAV file(s) from ${remote}?`)) return;
|
||
delAll.disabled = true;
|
||
const statusEl = panel.querySelector(".nonjav-status");
|
||
statusEl.textContent = `Deleting ${paths.length} file(s)…`;
|
||
const r = await chrome.runtime.sendMessage({ type: "delete-skipped", paths });
|
||
const ok = r?.deleted_count || 0;
|
||
const fail = r?.failed_count || 0;
|
||
if (ok) {
|
||
// Mark successfully deleted items
|
||
const deletedPaths = new Set(
|
||
(r.results || []).filter(x => x.ok).map(x => x.path)
|
||
);
|
||
allItems.forEach(i => {
|
||
if (deletedPaths.has(i.dataset.fullPath)) {
|
||
i.classList.add("deleted");
|
||
i.querySelector(".nonjav-del-one")?.remove();
|
||
}
|
||
});
|
||
_updateNonJavDelAll(panel);
|
||
}
|
||
statusEl.textContent = fail
|
||
? `Deleted ${ok}, failed ${fail}. Check deletion settings.`
|
||
: `Deleted ${ok} file(s).`;
|
||
}
|
||
});
|
||
return panel;
|
||
}
|
||
|
||
function _updateNonJavDelAll(panel) {
|
||
const remaining = panel.querySelectorAll(".nonjav-item:not(.deleted)").length;
|
||
const btn = panel.querySelector(".nonjav-del-all");
|
||
if (btn) {
|
||
btn.textContent = `Delete All (${remaining})`;
|
||
btn.disabled = remaining === 0;
|
||
}
|
||
}
|
||
|
||
cacheStatusOut.addEventListener("click", (event) => {
|
||
const showSkipped = event.target.closest(".cache-show-skipped");
|
||
if (showSkipped) {
|
||
const remote = showSkipped.dataset.remote;
|
||
// Toggle: if panel already open, close it
|
||
const existing = cacheStatusOut.querySelector(`.nonjav-panel[data-remote="${CSS.escape(remote)}"]`);
|
||
if (existing) { existing.remove(); showSkipped.textContent = showSkipped.textContent.replace("▴", "▾"); return; }
|
||
showSkipped.textContent = showSkipped.textContent.replace("▾", "▴");
|
||
// Find skipped items from last cache status result
|
||
const items = (_cacheSkippedByRemote?.get(remote)) || [];
|
||
const panel = _renderNonJavPanel(items, remote);
|
||
// Insert after the row containing this button
|
||
showSkipped.closest("div")?.after(panel);
|
||
return;
|
||
}
|
||
const refresh = event.target.closest(".cache-refresh-remote");
|
||
if (refresh) {
|
||
const remote = refresh.dataset.remote || "";
|
||
if (!remote) return;
|
||
// Per-remote Refresh is always incremental — inherit dropdown value if it's a
|
||
// duration (not "Full Rebuild"), otherwise default to 24h.
|
||
const dropdownVal = rebuildMode ? rebuildMode.value : "";
|
||
const refreshSince = dropdownVal || "24h";
|
||
_startOptScan([remote], refreshSince);
|
||
return;
|
||
}
|
||
});
|
||
|
||
document.getElementById("scan-job-clear").addEventListener("click", async () => {
|
||
if (!confirm("Clear recorded scan job history?\n\nThis only clears the Scan Job panel state. It does not change cache.json.")) return;
|
||
scanJobOut.textContent = "clearing scan job history...";
|
||
const r = await chrome.runtime.sendMessage({ type: "scan-clear" });
|
||
if (!r || !r.ok) {
|
||
scanJobOut.innerHTML = `<span style="color:#faa;">clear failed:</span> ${escapeHtml(r?.error || "no response")}`;
|
||
return;
|
||
}
|
||
_renderScanJob({ ok: true, no_state: true });
|
||
});
|
||
|
||
// If Options is opened while a scan is already running, attach to it instead
|
||
// of showing an idle Rebuild button.
|
||
_refreshScanJob();
|
||
chrome.runtime.sendMessage({ type: "scan-progress" }, (r) => {
|
||
if (chrome.runtime.lastError || !r || !r.ok) return;
|
||
_renderScanJob(r);
|
||
if (!r.scanning) return;
|
||
_setOptScanningState(true);
|
||
_optScanTimer = setInterval(_pollOptProgress, 1500);
|
||
});
|
||
})();
|
||
|
||
// ---------- adapters ----------
|
||
|
||
function renderAdapters(list) {
|
||
const tbody = document.querySelector("#adapters tbody");
|
||
tbody.innerHTML = "";
|
||
for (const a of list) addAdapterRow(a.host || "", a.selector || "");
|
||
if (list.length === 0) addAdapterRow("", "");
|
||
}
|
||
|
||
function addAdapterRow(host, selector) {
|
||
const tbody = document.querySelector("#adapters tbody");
|
||
const tr = document.createElement("tr");
|
||
tr.innerHTML = `
|
||
<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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
|
||
})();
|