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>
This commit is contained in:
@@ -135,12 +135,13 @@ Done in rc-jav catalog loading. Catalog CSV/XML paths are normalized from Window
|
|||||||
2. **CSS extracted from options.html.** Embedded `<style>` block moved to `options.css`, linked via `<link rel="stylesheet">`. options.html went 1179 → 794 lines. Inline `style="..."` attributes intentionally left for later (step 6 territory).
|
2. **CSS extracted from options.html.** Embedded `<style>` block moved to `options.css`, linked via `<link rel="stylesheet">`. options.html went 1179 → 794 lines. Inline `style="..."` attributes intentionally left for later (step 6 territory).
|
||||||
3. **Transfer Assistant wizard deleted.** "Setup & Transfer" pane renamed to "Setup". Replacement: Extension ID display + Copy button added to Diagnostics → Native host registration fieldset (always visible, not failure-gated). Sidebar entry, fieldset, modal, and ~107 lines of JS removed.
|
3. **Transfer Assistant wizard deleted.** "Setup & Transfer" pane renamed to "Setup". Replacement: Extension ID display + Copy button added to Diagnostics → Native host registration fieldset (always visible, not failure-gated). Sidebar entry, fieldset, modal, and ~107 lines of JS removed.
|
||||||
5. **Recent Activity + Search Troubleshooting moved to new Debug Tools pane.** Verified Recent Activity is search-trigger-only by reading `background.js` — `recordActivity()` is NOT called from `delete-file` handler. No audit-value split needed. New sidebar entry "Debug Tools" under System group; new `pane-debug` houses both fieldsets.
|
5. **Recent Activity + Search Troubleshooting moved to new Debug Tools pane.** Verified Recent Activity is search-trigger-only by reading `background.js` — `recordActivity()` is NOT called from `delete-file` handler. No audit-value split needed. New sidebar entry "Debug Tools" under System group; new `pane-debug` houses both fieldsets.
|
||||||
|
6. **options.js split — Cache & Scans + Duplicate Review paired extraction.** `options.js` 3133 → 2356 lines. New files: `options-cache.js` (161 lines, Cache & Scans block), `options-dupe-review.js` (616 lines, Dup Review + Keep Ranking incl. bottom `loadKeepRanking()` call). Script-tag order in `options.html`: cache → dupe-review → options.js (body bottom). Cross-script binding visibility (vanilla classic scripts share global declarative env): Library Issues code still in options.js reads `_configuredScanRoots` / `_cacheSkippedByRemote` / calls `rememberConfiguredScanRoots` from cache file by bare reference. Calls to `escapeHtml` / `openModal` / `closeModal` / `keepActionViewport` / `clearNativeRepairCard` / `renderNativeMessagingFailure` from extracted files all occur inside event handlers (resolved at call time, after options.js parses). Repo `git init`'d before this step; baseline commit `f8e781f` is the rollback point. Verified by `node --check` on each file and on concatenated script.
|
||||||
|
|
||||||
(Step 4 in the plan is a paired-extraction sub-task of step 6; not a separate ship.)
|
(Step 4 in the plan is a paired-extraction sub-task of step 6; folded into step 6 ship.)
|
||||||
|
|
||||||
**Pending (in execution order):**
|
**Pending (in execution order):**
|
||||||
|
|
||||||
- **Step 6 — options.js split, Cache & Scans + Duplicate Review paired.** Biggest, riskiest step. `options.js` is currently 3133 lines. Pair these two together because Dup Review reads cache state — extracting one while the other stays in monolith creates cross-module gap. Continue with Debug Tools, Library Issues, Settings sub-tabs after the pair lands.
|
- **Step 6b — continue options.js split with Library Issues, Debug Tools handlers, Settings sub-tabs.** Library Issues is the next obvious ~450-line block (lines 1505–1957 in pre-split numbering, now in options.js mid-section). Reads `_configuredScanRoots` and `_cacheSkippedByRemote` from `options-cache.js` — cross-file binding already exercised, so the extraction is lower risk than the first pair.
|
||||||
- **Step 7a — Bulk Check standalone window.** New `bulk-check.html` opened as detached `chrome.windows.create({ type: 'popup', width: 640, height: 540 })` from a "Bulk Check" launcher button in the popup. Single canonical entry path — NOT a Console sidebar tab. Window dedup via `chrome.storage.session`, last-paste persisted via `chrome.storage.local`.
|
- **Step 7a — Bulk Check standalone window.** New `bulk-check.html` opened as detached `chrome.windows.create({ type: 'popup', width: 640, height: 540 })` from a "Bulk Check" launcher button in the popup. Single canonical entry path — NOT a Console sidebar tab. Window dedup via `chrome.storage.session`, last-paste persisted via `chrome.storage.local`.
|
||||||
- **Step 8 — Shared fixture corpus.** Top-level `D:\DEV\Project\rclone-jav\fixtures\` (neutral location, NOT inside Python or extension repo). JSON cases for query-ID extraction (extension), filename ID extraction (Python), shared normalization.
|
- **Step 8 — Shared fixture corpus.** Top-level `D:\DEV\Project\rclone-jav\fixtures\` (neutral location, NOT inside Python or extension repo). JSON cases for query-ID extraction (extension), filename ID extraction (Python), shared normalization.
|
||||||
- **Step 9 — Cache contract design.** CACHE_VERSION already exists (currently 3). Add ID_RULES_VERSION concept: schema bump = force rebuild, rules bump = warn-and-mark-stale.
|
- **Step 9 — Cache contract design.** CACHE_VERSION already exists (currently 3). Add ID_RULES_VERSION concept: schema bump = force rebuild, rules bump = warn-and-mark-stale.
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// ---------- 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))}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,616 @@
|
|||||||
|
// ---------- 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();
|
||||||
@@ -801,6 +801,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="options-cache.js"></script>
|
||||||
|
<script src="options-dupe-review.js"></script>
|
||||||
<script src="options.js"></script>
|
<script src="options.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
-777
@@ -494,167 +494,6 @@ document.getElementById("add-current-site").addEventListener("click", async () =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- 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 ----------
|
// ---------- recent activity ----------
|
||||||
|
|
||||||
@@ -885,622 +724,6 @@ document.getElementById("bulk-id-clear").addEventListener("click", () => {
|
|||||||
document.getElementById("bulk-id-results").innerHTML = "";
|
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 ----
|
// ---- Library Issues ----
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user