Files
ext-rclone-jav/options-dupe-review.js
T
admin da09434419 Step 6: extract Cache & Scans + Duplicate Review from options.js
Splits the 3133-line options.js into three vanilla scripts loaded in
order at the bottom of options.html:

  options-cache.js        161 lines  (Cache & Scans block)
  options-dupe-review.js  616 lines  (Duplicate Review + Keep Ranking)
  options.js             2356 lines  (everything else)

No behavior change. Cross-file references work because classic <script>
tags share the global declarative environment: top-level `let` bindings
in options-cache.js (_configuredScanRoots, _cacheSkippedByRemote) are
visible by bare reference in options.js, where Library Issues still
reads them. Calls into options.js from the extracted files
(escapeHtml, openModal/closeModal, keepActionViewport,
clearNativeRepairCard, renderNativeMessagingFailure) all occur inside
event handlers, resolved at call time after options.js parses.

node --check passes on each file individually and on the concatenation
of all three in load order. Brace counts balanced.

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

617 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---------- 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();