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 = "dupe-review";
if (pane === "maintenance") pane = "dupe-review";
const item = document.querySelector(`.side .item[data-pane="${pane}"]`) || document.querySelector('.side .item[data-pane="dupe-review"]');
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 });
});
}
// ---------- sidebar badges ----------
function fmtBadgeAge(hours) {
if (!Number.isFinite(hours) || hours < 0) return "";
if (hours < 1) return `${Math.max(1, Math.round(hours * 60))}m`;
if (hours < 48) return `${Math.round(hours)}h`;
return `${Math.round(hours / 24)}d`;
}
function setBadge(key, text, tone) {
const el = document.querySelector(`.side .side-badge[data-badge="${key}"]`);
if (!el) return;
el.textContent = text || "";
el.classList.remove("warn", "fresh");
if (tone) el.classList.add(tone);
}
async function refreshSidebarBadges() {
try {
const s = await chrome.storage.local.get([
"badge_dupe_count",
"badge_cache_age_hours",
"badge_cache_stale_hours",
"badge_library_issues_count",
]);
const dupe = Number(s.badge_dupe_count);
setBadge("dupe-count", dupe > 0 ? String(dupe) : "", dupe > 0 ? "warn" : "");
const age = Number(s.badge_cache_age_hours);
const stale = Number(s.badge_cache_stale_hours) || 24;
if (Number.isFinite(age)) {
setBadge("cache-age", fmtBadgeAge(age), age <= stale ? "fresh" : "warn");
} else {
setBadge("cache-age", "", "");
}
const issues = Number(s.badge_library_issues_count);
setBadge("library-issues-count", issues > 0 ? String(issues) : "", "");
} catch {}
}
refreshSidebarBadges();
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "local") return;
if (
"badge_dupe_count" in changes ||
"badge_cache_age_hours" in changes ||
"badge_cache_stale_hours" in changes ||
"badge_library_issues_count" in changes
) {
refreshSidebarBadges();
}
});
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 || "";
const dw = document.getElementById("discordWebhookUrl");
if (dw) dw.value = settings.discordWebhookUrl || "";
const pcl = document.getElementById("pcLabel");
if (pcl) pcl.value = settings.pcLabel || "";
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 discordWebhookUrl = (document.getElementById("discordWebhookUrl")?.value || "").trim();
const pcLabel = (document.getElementById("pcLabel")?.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,
discordWebhookUrl, pcLabel,
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 });
// Push alerts config to the host so its abnormal-disconnect / exception
// / write-error paths can post Discord webhooks too (catches failures the
// extension-side recordRpc never sees, like host being killed mid-write).
// Fire-and-forget — never block the SAVE on host availability.
chrome.runtime.sendMessage({
type: "save-alerts-config",
discordWebhookUrl, pcLabel,
}).catch(() => {});
} catch (err) {
// chrome.storage.sync has 8 KB/item + 100 KB total quota. Long adapter or
// normalizer lists can blow it; without this try/catch the rejection is
// silently swallowed and the "Saved." chip never appears.
if (saved && saved.classList.contains("saved")) {
saved.textContent = "Save failed: " + (err.message || String(err));
saved.classList.add("show", "error");
setTimeout(() => { saved.classList.remove("show", "error"); saved.textContent = "Saved."; }, 4000);
} else {
alert("Failed to save settings: " + (err.message || String(err)));
}
return;
}
chrome.runtime.sendMessage({ type: "settings-changed" });
updateSectionSummaries();
// Show "Saved." chip on the button that triggered this
if (saved && saved.classList.contains("saved")) {
saved.classList.add("show");
setTimeout(() => saved.classList.remove("show"), 1500);
}
}
for (const btn of document.querySelectorAll(".save-btn")) {
btn.addEventListener("click", save);
}
function plural(n, word) {
return `${n} ${word}${n === 1 ? "" : "s"}`;
}
function setNote(id, html, kind = "") {
const el = document.getElementById(id);
if (!el) return;
el.className = "section-note" + (kind ? ` ${kind}` : "");
el.innerHTML = html;
}
function updateSectionSummaries() {
const knownSites = document.getElementById("knownSitePatterns").value
.split(/[\n,]/).map((s) => s.trim()).filter(Boolean);
const triggersOn = KEYS.filter((k) => document.getElementById(k)?.checked);
const autoOn = document.getElementById("autoEveryPage").checked || document.getElementById("autoKnownSites").checked;
setNote("trigger-summary",
`${autoOn ? "Auto-check is enabled" : "Auto-check is off"} · ${plural(knownSites.length, "known site")} · ${plural(triggersOn.length, "manual/navigation control")} enabled`,
autoOn ? "" : "warn");
const match = document.getElementById("showOverlay").checked;
const noMatch = document.getElementById("noMatchOverlay").checked;
setNote("overlay-summary", `Match overlay ${match ? "on" : "off"} · no-match overlay ${noMatch ? "on" : "off"} · positions are kept separate to avoid overlap`, match || noMatch ? "" : "warn");
const adapters = readAdapters();
setNote("adapter-summary", `${plural(adapters.length, "custom adapter")} configured · built-in ClearJAV preset always runs`, adapters.length ? "" : "info");
const normalizers = readNormalizers();
const partPatterns = readPartDetectors();
setNote("normalizer-summary", `${plural(normalizers.length, "custom normalizer")} · ${plural(partPatterns.length, "custom part detector")} · built-in FC2 and multipart rules active`, normalizers.length || partPatterns.length ? "" : "info");
const quick = document.getElementById("quickMode").checked;
setNote("search-summary", quick ? "LIVE mode: single-ID lookups query rclone directly and bypass cache." : "CACHE mode: lookups use cache.json; rebuild cache after library or ID-normalization changes.");
const path = document.getElementById("rcjavPath").value.trim();
setNote("paths-summary", path ? `Using extension override: ${escapeHtml(path)}` : "Using native host default rc-jav path.", path ? "" : "info");
const profiles = readProfiles();
setNote("profiles-summary", profiles.length ? `${plural(profiles.length, "profile")} configured. Empty source/target lists inherit config.json defaults.` : "No profiles configured. Popup searches use rc-jav config.json defaults.", profiles.length ? "" : "info");
const settingCount = Object.keys(SETTINGS_SCHEMA).length;
setNote("backup-summary", `Backups include ${settingCount} recognized settings keys. Export before moving the extension or changing its ID.`, "info");
const deleteOn = document.getElementById("enableDelete").checked;
const mode = document.getElementById("deleteModePerm").checked ? "permanent delete" : "trash mode";
setNote("deletion-summary", deleteOn ? `Deletion is enabled in ${mode}. Popup confirmation still requires typing the exact filename.` : "Deletion is disabled. Popup delete controls stay hidden.", deleteOn ? "danger" : "");
}
function syncDeletionControls() {
const enabled = document.getElementById("enableDelete").checked;
for (const id of ["deleteModeTrash", "deleteModePerm", "trashDir"]) {
const el = document.getElementById(id);
if (el) el.disabled = !enabled;
}
for (const el of [document.getElementById("deleteModeTrashLbl")?.closest(".fieldset"), document.getElementById("trashDir")?.closest(".fieldset")]) {
if (el) el.classList.toggle("disabled-soft", !enabled);
}
}
document.getElementById("enableDelete").addEventListener("change", (event) => {
if (!event.target.checked) return;
event.target.checked = false;
syncDeletionControls();
updateSectionSummaries();
openModal("delete-enable-modal");
});
function closeDeleteEnableModal() {
closeModal("delete-enable-modal");
}
document.getElementById("delete-enable-modal-confirm").addEventListener("click", () => {
document.getElementById("enableDelete").checked = true;
closeDeleteEnableModal();
syncDeletionControls();
updateSectionSummaries();
});
for (const id of ["delete-enable-modal-close", "delete-enable-modal-cancel"]) {
document.getElementById(id).addEventListener("click", closeDeleteEnableModal);
}
document.getElementById("delete-enable-modal").addEventListener("click", (event) => {
if (event.target.id === "delete-enable-modal") closeDeleteEnableModal();
});
document.addEventListener("input", (e) => {
if (e.target && e.target.closest(".pane")) updateSectionSummaries();
});
document.addEventListener("change", (e) => {
if (e.target && e.target.closest(".pane")) {
if (e.target.id === "enableDelete") syncDeletionControls();
updateSectionSummaries();
}
});
// ---------- backup & restore ----------
document.getElementById("export-settings").addEventListener("click", async () => {
const { settings = {} } = await chrome.storage.sync.get("settings");
// Pull keep_ranking from the Python config.json via the host RPC. Lives
// outside chrome.storage so without this step Export drops VIP folders,
// size tolerance, format preference, and tiebreak prefs.
//
// FIX S-1 (bugs-fix-queue.md): the previous implementation silently swallowed
// RPC failures and wrote an export file with `_meta.host_config: {}` while
// status said "exported." That's a silent backup-data-loss bug — user trusts
// the backup; on later restore the missing keep_ranking surfaces only as a
// dismissable info row, and user loses VIP folders / format prefs.
//
// New behavior: if get-keep-ranking fails for any reason (host down, port
// broken, timeout, response not ok), BLOCK the export entirely and show a
// clear retry message. Backup must be all-or-nothing — partial backup files
// lying around are too easy to misuse months later when the user has
// forgotten which file was complete.
setBackupStatus("fetching keep ranking...", "");
let keepRanking = null;
try {
const r = await chrome.runtime.sendMessage({ type: "get-keep-ranking" });
if (!r || !r.ok) {
const reason = r?.error ? `: ${r.error}` : (r?.error_kind ? ` (${r.error_kind})` : "");
setBackupStatus(`export blocked — host could not return keep ranking${reason}. Confirm native host is running, then retry.`, "fail");
return;
}
if (!r.keep_ranking || typeof r.keep_ranking !== "object") {
setBackupStatus("export blocked — host returned no keep_ranking payload. Run Setup → Test (host) to diagnose, then retry.", "fail");
return;
}
keepRanking = r.keep_ranking;
} catch (e) {
setBackupStatus(`export blocked — keep ranking fetch threw: ${e?.message || e}. Reload the extension or restart the host, then retry.`, "fail");
return;
}
const payload = {
_meta: {
app: "rclone-jav",
exported_at: new Date().toISOString(),
version: 2, // v2: adds _meta.host_config.keep_ranking
host_config: { keep_ranking: keepRanking },
},
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 = `rclone-jav-settings-${stamp}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setBackupStatus("exported (settings + keep_ranking).", "ok");
});
document.getElementById("import-settings").addEventListener("click", () => {
document.getElementById("import-file").click();
});
// ---- Discord webhook test (host-side) ----
document.getElementById("test-discord-host")?.addEventListener("click", async (e) => {
const btn = e.currentTarget;
const status = document.getElementById("discord-status");
const url = (document.getElementById("discordWebhookUrl")?.value || "").trim();
if (!url) {
if (status) { status.textContent = "Paste a webhook URL first."; status.style.color = "#888"; }
return;
}
const pcLabel = (document.getElementById("pcLabel")?.value || "").trim();
// Push current URL+label to the host before testing so it reads fresh values.
await chrome.runtime.sendMessage({ type: "save-alerts-config", discordWebhookUrl: url, pcLabel });
btn.disabled = true;
const orig = btn.textContent;
btn.textContent = "Sending…";
if (status) { status.textContent = ""; status.style.color = "#888"; }
try {
const r = await chrome.runtime.sendMessage({ type: "test-host-alert" });
if (r?.ok) {
if (status) { status.textContent = "Host posted. Check Discord."; status.style.color = "#9be3b3"; }
} else {
if (status) { status.textContent = "Failed: " + (r?.error || "no response"); status.style.color = "#ff9097"; }
}
} catch (err) {
if (status) { status.textContent = "Failed: " + (err.message || err); status.style.color = "#ff9097"; }
} finally {
btn.disabled = false;
btn.textContent = orig;
}
});
// ---- Discord webhook test (extension-side) ----
document.getElementById("test-discord-webhook")?.addEventListener("click", async (e) => {
const btn = e.currentTarget;
const status = document.getElementById("discord-status");
const url = (document.getElementById("discordWebhookUrl")?.value || "").trim();
if (!url) {
if (status) { status.textContent = "Paste a webhook URL first."; status.className = "muted"; }
return;
}
// Save current value first so background reads the latest URL.
const existing = await chrome.storage.sync.get("settings");
const merged = Object.assign({}, existing.settings || {}, {
discordWebhookUrl: url,
pcLabel: (document.getElementById("pcLabel")?.value || "").trim(),
});
await chrome.storage.sync.set({ settings: merged });
btn.disabled = true;
const orig = btn.textContent;
btn.textContent = "Sending…";
if (status) { status.textContent = ""; status.className = "muted"; }
try {
const r = await chrome.runtime.sendMessage({ type: "test-discord-webhook" });
if (r?.ok) {
if (status) { status.textContent = `Sent (HTTP ${r.status}). Check Discord.`; status.className = "muted"; status.style.color = "#9be3b3"; }
} else {
const detail = r?.error || `HTTP ${r?.status ?? "?"}`;
if (status) { status.textContent = `Failed: ${detail}`; status.className = "muted"; status.style.color = "#ff9097"; }
}
} catch (err) {
if (status) { status.textContent = "Failed: " + (err.message || err); status.style.color = "#ff9097"; }
} finally {
btn.disabled = false;
btn.textContent = orig;
}
});
// 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",
discordWebhookUrl: "string",
pcLabel: "string",
};
function _typeOf(v) {
if (Array.isArray(v)) return "array";
if (v === null) return "null";
return typeof v;
}
function _isPlainObject(v) {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function _isStringArray(v) {
return Array.isArray(v) && v.every((x) => typeof x === "string");
}
// Per-key element validators for arrays of structured elements. Returns true
// if the element's shape is acceptable for downstream consumers; false means
// the element would crash content.js / id-extract.js / host RPC consumers and
// must be dropped.
//
// FIX M-1 (bugs-fix-queue.md): the prior sanitizer checked outer array type
// only, so an import with siteAdapters: [{host: 123, selector: []}] passed
// through to chrome.storage.sync. Later, content.js's tryAdapters called
// a.selector.split(",") which threw TypeError, breaking ID extraction on
// every web page until the user manually repaired settings.
//
// Each validator accepts older exports (missing optional fields are OK) but
// rejects shapes that don't match the consumer contract. Extra unknown fields
// on an element are tolerated — only the REQUIRED-for-consumer fields are
// checked, so v1 exports with extra metadata still import cleanly.
const ARRAY_ELEMENT_VALIDATORS = {
siteAdapters: (e) =>
_isPlainObject(e)
&& typeof e.host === "string"
&& typeof e.selector === "string",
idNormalizers: (e) =>
_isPlainObject(e)
&& (typeof e.re === "string" || e.re instanceof RegExp)
&& typeof e.fmt === "string",
partPatterns: (e) => typeof e === "string",
knownSitePatterns: (e) => typeof e === "string",
profiles: (e) =>
_isPlainObject(e)
&& typeof e.name === "string"
&& _isStringArray(e.source || [])
&& _isStringArray(e.target || []),
};
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; }
// Per-element validation for arrays whose elements have a required shape.
if (expected === "array" && ARRAY_ELEMENT_VALIDATORS[k]) {
const validator = ARRAY_ELEMENT_VALIDATORS[k];
const goodElements = [];
let badCount = 0;
v.forEach((el, idx) => {
if (validator(el)) {
goodElements.push(el);
} else {
badCount++;
// Cap per-element dropped lines so the import modal stays readable
// if someone imports a totally malformed file.
if (dropped.length < 30) dropped.push(`${k}[${idx}](malformed)`);
}
});
if (badCount > 0 && goodElements.length === 0) {
// Every element was bad — drop the key entirely so mergeSettings can
// fall back to defaults rather than persisting an empty (but typed)
// array that masks the underlying corruption.
dropped.push(`${k}(all ${v.length} elements malformed — falling back to defaults)`);
continue;
}
out[k] = goodElements;
} else {
out[k] = v;
}
}
return { sanitized: out, dropped };
}
let pendingImport = null;
function closeImportModal() {
pendingImport = null;
closeModal("import-modal");
}
function openImportModal(fileName, sanitized, dropped, keepRanking) {
pendingImport = { sanitized, dropped, keepRanking };
document.getElementById("import-modal-subtitle").textContent = fileName || "settings JSON";
const krRow = keepRanking
? `