Files
ext-rclone-jav/src/options/options-library-issues.js
T
admin 2d6a95682f Sync working tree before initial Gitea push
- File reorg: popup/options/bulk-check moved to src/ subdirs
- Shared modules: src/shared/id-extract.js, src/options/options-shared.js
- Host updates: rcjav-host.py + register/install scripts
- .gitignore expanded
2026-05-26 22:42:15 +02:00

714 lines
31 KiB
JavaScript

// ---- Library Issues ----
let lastLibraryIssues = null;
let _libraryIssuesDirty = false;
let _libraryIssueTypeFilter = "all";
let _missingResolutionExtFilter = "all";
function _libraryIssueExportItems(r) {
const missingRes = r?.missing_resolution || [];
const visibleMissingRes = _missingResolutionExtFilter === "all"
? missingRes
: missingRes.filter((e) => e.extension === _missingResolutionExtFilter);
const includeAll = _libraryIssueTypeFilter === "all";
return {
bracketNames: includeAll ? (r?.bracket_names || []) : [],
noHyphenNames: includeAll ? (r?.nohyphen_names || []) : [],
resolutionNoncanonical: includeAll || _libraryIssueTypeFilter === "noncanonical"
? (r?.resolution_noncanonical || [])
: [],
missingResolution: includeAll || _libraryIssueTypeFilter === "missing" ? visibleMissingRes : [],
};
}
function _safeExportToken(value) {
return String(value || "all").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "") || "all";
}
function _downloadJson(filename, data) {
const blob = new Blob([JSON.stringify(data, null, 2) + "\n"], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function _libraryIssueKindLabel(entry) {
const labels = {
resolution_copy_suffix: "copy suffix",
resolution_part_suffix: "part suffix",
resolution_bare_suffix: "bare res",
resolution_placeholder_empty: "empty []",
quality_marker_not_resolution: "quality tag",
suspicious_bracket_token: "bad bracket",
multipart_without_resolution: "part marker",
missing_resolution: "missing res",
};
const kinds = (entry.issues || [])
.map((issue) => labels[issue.kind] || issue.kind)
.filter(Boolean);
return kinds.length ? kinds.join(" · ") : "Report only";
}
function _canRenameIdFixRow(row) {
return row
&& !row.classList.contains("report-only")
&& ["bracket_id", "nohyphen_id"].includes(row.dataset.issue)
&& row.dataset.remote
&& row.dataset.old
&& row.dataset.new;
}
function renderLibraryIssues(r) {
const out = document.getElementById("library-issues-modal-body");
const statusEl = document.getElementById("library-issues-results");
const renameAllBtn = document.getElementById("library-issues-rename-all");
const exportBtn = document.getElementById("library-issues-export");
const renameStatus = document.getElementById("library-issues-rename-status");
if (!r || !r.ok) {
lastLibraryIssues = null;
renameAllBtn.disabled = true;
exportBtn.disabled = true;
out.innerHTML = `<div class="li-empty" style="color:#f87171;">Error: ${escapeHtml(r?.error || "no response")}</div>`;
openModal("library-issues-modal");
return;
}
lastLibraryIssues = r;
const brackets = r.bracket_names || [];
const nohyphens = r.nohyphen_names || [];
const missingRes = r.missing_resolution || [];
const noncanonicalRes = r.resolution_noncanonical || [];
const renameableTotal = brackets.length + nohyphens.length;
const total = renameableTotal + missingRes.length + noncanonicalRes.length;
const showRenameable = _libraryIssueTypeFilter === "all";
const showNoncanonical = _libraryIssueTypeFilter === "all" || _libraryIssueTypeFilter === "noncanonical";
const showMissing = _libraryIssueTypeFilter === "all" || _libraryIssueTypeFilter === "missing";
try { chrome.storage.local.set({ badge_library_issues_count: total }); } catch {}
renameAllBtn.disabled = !showRenameable || renameableTotal === 0;
renameAllBtn.title = renameableTotal
? "Rename only bracket-wrapped and no-hyphen ID fixes"
: "No bracket-wrapped or no-hyphen ID fixes to rename";
exportBtn.disabled = total === 0;
renameStatus.textContent = "";
const parts = [];
if (!total) {
parts.push(`<div class="li-empty">✓ No library issues found. All filenames are canonical.</div>`);
} else {
const typeButtons = [
["all", "All", total],
["noncanonical", "Noncanonical", noncanonicalRes.length],
["missing", "Missing res", missingRes.length],
].map(([type, label, count]) => (
`<button type="button" class="li-filter-chip li-type-chip${_libraryIssueTypeFilter === type ? " active" : ""}" data-type-filter="${escapeHtml(type)}">
<span>${escapeHtml(label)}</span><span>${Number(count).toLocaleString()}</span>
</button>`
)).join("");
parts.push(`<div class="li-stats with-filters">
<span><b>${total}</b> cache issue${total !== 1 ? "s" : ""} — <b>${brackets.length}</b> bracket-wrapped, <b>${nohyphens.length}</b> no-hyphen, <b>${missingRes.length}</b> missing resolution tag, <b>${noncanonicalRes.length}</b> noncanonical resolution</span>
<span class="li-filter-group">${typeButtons}</span>
</div>`);
const makeRow = (entry, tagClass, tagLabel) => {
const fname = entry.path.split("/").pop();
const dir = entry.path.lastIndexOf("/") !== -1 ? entry.path.slice(0, entry.path.lastIndexOf("/") + 1) : "";
return `<div class="li-row" data-issue="${escapeHtml(entry.issue)}" data-remote="${escapeHtml(entry.remote)}" data-old="${escapeHtml(entry.path)}" data-new="${escapeHtml(dir + entry.canonical_name)}">
<span class="li-tag ${tagClass}">${tagLabel}</span>
<div class="li-names">
<span class="li-old" title="${escapeHtml(entry.path)}">${escapeHtml(fname)}</span>
<span class="li-new" title="${escapeHtml(entry.canonical_name)}">→ ${escapeHtml(entry.canonical_name)}</span>
</div>
<span class="li-sz">${escapeHtml(entry.size_human || "")}</span>
<button class="li-rename-btn" type="button">Rename</button>
</div>`;
};
const makeReportRow = (entry, tagLabel = "no res", tagClass = "missingres") => {
const fname = entry.filename || entry.path.split("/").pop();
return `<div class="li-row report-only" data-remote="${escapeHtml(entry.remote)}" data-old="${escapeHtml(entry.path)}">
<span class="li-tag ${tagClass}">${escapeHtml(tagLabel)}</span>
<div class="li-names">
<span class="li-old" title="${escapeHtml(entry.full_path || entry.path)}">${escapeHtml(fname)}</span>
<span class="li-new" title="${escapeHtml(_libraryIssueKindLabel(entry))}">${escapeHtml(entry.path)}</span>
</div>
<span class="li-sz">${escapeHtml(entry.size_human || "")}</span>
<span class="li-action-note">${escapeHtml(_libraryIssueKindLabel(entry))}</span>
</div>`;
};
if (showRenameable && brackets.length) {
parts.push(`<div class="li-section-head">Bracket-wrapped IDs (${brackets.length})</div>`);
parts.push(brackets.map((e) => makeRow(e, "bracket", "[ ]")).join(""));
}
if (showRenameable && nohyphens.length) {
parts.push(`<div class="li-section-head">No-hyphen IDs (${nohyphens.length})</div>`);
parts.push(nohyphens.map((e) => makeRow(e, "nohyphen", "no hyphen")).join(""));
}
if (showNoncanonical && noncanonicalRes.length) {
parts.push(`<div class="li-section-head">Resolution present, noncanonical (${noncanonicalRes.length})</div>`);
parts.push(noncanonicalRes.map((e) => makeReportRow(e, "res style", "noncanonres")).join(""));
}
if (showMissing && missingRes.length) {
const summary = r.missing_resolution_summary || {};
const byExt = summary.by_extension || {};
const extEntries = Object.entries(byExt).sort(([a], [b]) => a.localeCompare(b));
const extButtons = [
["all", "All", missingRes.length],
...extEntries.map(([ext, count]) => [ext, ext, count]),
].map(([ext, label, count]) => (
`<button type="button" class="li-filter-chip${_missingResolutionExtFilter === ext ? " active" : ""}" data-ext-filter="${escapeHtml(ext)}">
<span>${escapeHtml(label)}</span><span>${Number(count).toLocaleString()}</span>
</button>`
)).join("");
const visibleMissingRes = _libraryIssueExportItems(r).missingResolution;
parts.push(`<div class="li-section-head with-filters">
<span>Missing resolution tag (${missingRes.length})</span>
<span class="li-filter-group">${extButtons}</span>
</div>`);
parts.push(visibleMissingRes.map((e) => makeReportRow(e)).join(""));
}
}
out.innerHTML = parts.join("");
statusEl.textContent = total
? `${total} library issue(s) found. Review window is open.`
: "No library issues found.";
openModal("library-issues-modal");
// Per-row rename buttons
out.querySelectorAll(".li-rename-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
const row = btn.closest(".li-row");
if (!_canRenameIdFixRow(row)) return;
const remote = row.dataset.remote;
const oldPath = row.dataset.old;
const newPath = row.dataset.new;
btn.disabled = true;
btn.textContent = "…";
const res = await chrome.runtime.sendMessage({
type: "rename_file", remote, old_path: oldPath, new_path: newPath,
});
const tag = row.querySelector(".li-tag");
if (res?.ok) {
tag.className = "li-tag done";
tag.textContent = "✓";
btn.textContent = "Done";
row.querySelector(".li-old").style.textDecoration = "line-through";
_libraryIssuesDirty = true;
} else if (res?.conflict) {
tag.className = "li-tag conflict";
tag.textContent = "conflict";
btn.textContent = "Skip";
renameStatus.textContent = `Conflict: ${res.error || "target exists"}`;
} else {
tag.className = "li-tag conflict";
tag.textContent = "error";
btn.textContent = "Error";
renameStatus.textContent = res?.error || "rename failed";
}
});
});
out.querySelectorAll(".li-filter-chip").forEach((btn) => {
btn.addEventListener("click", () => {
if (btn.dataset.typeFilter) {
_libraryIssueTypeFilter = btn.dataset.typeFilter || "all";
if (_libraryIssueTypeFilter !== "missing") _missingResolutionExtFilter = "all";
} else {
_missingResolutionExtFilter = btn.dataset.extFilter || "all";
_libraryIssueTypeFilter = "missing";
}
renderLibraryIssues(lastLibraryIssues);
});
});
}
document.getElementById("library-issues-run").addEventListener("click", async () => {
const out = document.getElementById("library-issues-modal-body");
out.innerHTML = `<div class="li-stats">Loading library issues from cache…</div>`;
openModal("library-issues-modal");
renderLibraryIssues(await chrome.runtime.sendMessage({ type: "library_issues" }));
});
document.getElementById("library-issues-rename-all").addEventListener("click", async () => {
const rows = [...document.querySelectorAll("#library-issues-modal-body .li-row")];
const renameStatus = document.getElementById("library-issues-rename-status");
const renameAllBtn = document.getElementById("library-issues-rename-all");
// Collect only legacy ID-fix renames. Resolution hygiene rows are report-only
// until they have explicit, reviewed rename proposals.
const pending = rows.reduce((acc, row) => {
const btn = row.querySelector(".li-rename-btn");
if (!btn || btn.disabled || !_canRenameIdFixRow(row)) return acc;
acc.push({ row, remote: row.dataset.remote, old_path: row.dataset.old, new_path: row.dataset.new });
return acc;
}, []);
if (!pending.length) {
renameStatus.textContent = "No ID-fix rows are available to rename.";
return;
}
const previewLimit = 12;
const previewLines = pending.slice(0, previewLimit).map(({ old_path, new_path }) => (
`${old_path}\n -> ${new_path}`
));
const remaining = pending.length - previewLines.length;
const ok = confirm(
`Rename ${pending.length} ID-fix file(s)?\n\n`
+ previewLines.join("\n\n")
+ (remaining > 0 ? `\n\n...and ${remaining} more.` : "")
);
if (!ok) {
renameStatus.textContent = "Rename ID fixes cancelled.";
return;
}
renameAllBtn.disabled = true;
renameStatus.textContent = `Renaming ${pending.length} ID-fix file(s)…`;
const renames = pending.map(({ remote, old_path, new_path }) => ({ remote, old_path, new_path }));
const res = await chrome.runtime.sendMessage({ type: "rename_files_batch", renames });
const results = res?.results || [];
let done = 0, conflicts = 0, errors = 0;
results.forEach((r, i) => {
const { row } = pending[i];
const tag = row.querySelector(".li-tag");
const btn = row.querySelector(".li-rename-btn");
if (r.ok) {
tag.className = "li-tag done"; tag.textContent = "✓";
btn.disabled = true; btn.textContent = "Done";
row.querySelector(".li-old").style.textDecoration = "line-through";
done++;
} else if (r.conflict) {
tag.className = "li-tag conflict"; tag.textContent = "conflict";
btn.disabled = false; btn.textContent = "Skip";
conflicts++;
} else {
tag.className = "li-tag conflict"; tag.textContent = "error";
btn.disabled = false; btn.textContent = "Error";
errors++;
}
});
const parts = [];
if (done) parts.push(`${done} renamed`);
if (conflicts) parts.push(`${conflicts} conflict(s)`);
if (errors) parts.push(`${errors} error(s)`);
renameStatus.textContent = parts.join(" · ") || "Nothing to rename.";
renameAllBtn.disabled = false;
_libraryIssuesDirty = done > 0;
});
document.getElementById("library-issues-export").addEventListener("click", () => {
if (!lastLibraryIssues?.ok) return;
const { bracketNames, noHyphenNames, resolutionNoncanonical, missingResolution } = _libraryIssueExportItems(lastLibraryIssues);
const activeFilter = _missingResolutionExtFilter || "all";
const activeType = _libraryIssueTypeFilter || "all";
const payload = {
export_type: "rclone_jav_library_issues",
generated_at: new Date().toISOString(),
source: "cache",
active_issue_type_filter: activeType,
active_missing_resolution_filter: activeFilter,
counts: {
bracket_wrapped: bracketNames.length,
no_hyphen: noHyphenNames.length,
resolution_noncanonical: resolutionNoncanonical.length,
missing_resolution: missingResolution.length,
total: bracketNames.length + noHyphenNames.length + resolutionNoncanonical.length + missingResolution.length,
full_cache_missing_resolution: lastLibraryIssues.missing_resolution?.length || 0,
full_cache_resolution_noncanonical: lastLibraryIssues.resolution_noncanonical?.length || 0,
},
bracket_names: bracketNames,
nohyphen_names: noHyphenNames,
resolution_noncanonical: resolutionNoncanonical,
missing_resolution: missingResolution,
};
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const filterToken = _safeExportToken(`${activeType}-${activeType === "missing" ? activeFilter : "all"}`);
_downloadJson(`rclone-jav-library-issues-${filterToken}-${stamp}.json`, payload);
const renameStatus = document.getElementById("library-issues-rename-status");
renameStatus.textContent = `Exported ${payload.counts.total.toLocaleString()} row(s) as JSON.`;
});
function _closeLibraryIssues() {
closeModal("library-issues-modal");
if (_libraryIssuesDirty) {
_libraryIssuesDirty = false;
chrome.runtime.sendMessage({ type: "library_issues" }, (r) => {
if (!r || !r.ok) return;
const total = (r.bracket_names?.length || 0)
+ (r.nohyphen_names?.length || 0)
+ (r.missing_resolution?.length || 0)
+ (r.resolution_noncanonical?.length || 0);
document.getElementById("library-issues-results").textContent = total
? `${total} library issue(s) found. Review window is open.`
: "No library issues found.";
});
}
}
for (const id of ["library-issues-modal-close", "library-issues-modal-done"]) {
document.getElementById(id).addEventListener("click", _closeLibraryIssues);
}
document.getElementById("library-issues-modal").addEventListener("click", (e) => {
if (e.target.id === "library-issues-modal") _closeLibraryIssues();
});
(function () {
const rebuildBtn = document.getElementById("cache-rebuild-run");
const rebuildMode = document.getElementById("cache-rebuild-mode");
const cacheStatusOut = document.getElementById("cache-status-results");
const scanJobOut = document.getElementById("scan-job-results");
let _optScanTimer = null;
let _optScanning = false;
const _stopOptPoll = () => { if (_optScanTimer) { clearInterval(_optScanTimer); _optScanTimer = null; } };
function _setOptScanningState(scanning) {
_optScanning = scanning;
rebuildBtn.textContent = scanning ? "✕ Cancel" : "Rebuild Cache";
if (rebuildMode) rebuildMode.disabled = scanning;
rebuildBtn.style.background = scanning ? "#3a1a1a" : "";
rebuildBtn.style.borderColor = scanning ? "#722" : "";
rebuildBtn.style.color = scanning ? "#faa" : "";
}
function _scanStatus(r) {
if (!r || r.no_state) return "idle";
if (r.scanning && !r.done) return "running";
if (r.cancelled) return "cancelled";
if (r.scan_ok === false) return "failed";
if (r.done) return "completed";
return "idle";
}
function _formatScanDuration(seconds) {
const s = Math.max(0, Math.round(Number(seconds) || 0));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
if (m < 60) return `${m}m ${rem}s`;
const h = Math.floor(m / 60);
const mm = m % 60;
return `${h}h ${mm}m`;
}
function _renderScanJob(r) {
if (!r || r.no_state) {
scanJobOut.innerHTML = `<span style="color:#777;">no scan job recorded yet</span>`;
return;
}
const status = _scanStatus(r);
const pillCls = status === "completed" ? "ok" : status === "failed" ? "fail" : "";
const jobLabel = status === "running" ? "Current Scan Job" : "Last Scan Job";
const mode = r.scan_since ? `incremental ${r.scan_since}` : "full";
const scope = (r.scope && r.scope.length) ? r.scope.join(", ") : "configured scan roots";
const finished = r.finished_at || r.started_at || "";
const when = finished ? new Date(finished).toLocaleString() : "";
let elapsed = r.elapsed_s != null ? _formatScanDuration(r.elapsed_s) : "";
if (!elapsed && r.started_at) {
const startedMs = Date.parse(r.started_at);
if (Number.isFinite(startedMs)) elapsed = _formatScanDuration((Date.now() - startedMs) / 1000);
}
const scanPct = Number.isFinite(r.scan_percent) ? `${Number(r.scan_percent).toFixed(1)}%${r.scan_total_known_complete === false ? " known" : ""}` : "";
const eta = r.scanning
? (Number.isFinite(r.scan_eta_s) ? _formatScanDuration(r.scan_eta_s) : "calculating")
: (status === "completed" ? "done" : "");
const knownCount = Number.isFinite(r.scan_files_done) && Number.isFinite(r.scan_files_total_known)
? `${Number(r.scan_files_done).toLocaleString()} / ${Number(r.scan_files_total_known).toLocaleString()}`
: "";
const meta = [mode, scope].filter(Boolean).join(" · ");
const metrics = [
["Progress", scanPct || "0.0%"],
["ETA", eta || "--"],
["Files", knownCount || "--"],
["Elapsed", elapsed || "0s"],
];
const jobs = (r.remote_jobs && r.remote_jobs.length)
? r.remote_jobs
: (r.remotes || []).map((remote, i) => ({
remote,
status: remote === r.current_remote ? status : i < (r.current_index || 0) ? "completed" : "queued",
files: remote === r.current_remote ? r.files_this_remote : null,
total: remote === r.current_remote ? r.files_remote_total : null,
}));
const jobRoots = jobs.map((j) => j.remote).filter(Boolean);
const retiredRoots = _configuredScanRoots.length
? jobRoots.filter((root) => !_configuredScanRoots.includes(root))
: [];
const jobRows = jobs.map((j) => {
const files = Number.isFinite(j.files) ? Number(j.files).toLocaleString() : "?";
const total = Number.isFinite(j.total) ? Number(j.total).toLocaleString() : "";
const pct = Number.isFinite(j.files) && Number.isFinite(j.total) && j.total > 0
? Math.min(100, Math.round((j.files / j.total) * 100)) : null;
const detail = [
j.label,
j.incremental ? "incremental" : "",
`${files}${total ? ` / ${total}` : ""} files`,
Number.isFinite(j.skipped) && j.skipped ? `${j.skipped} skipped` : "",
].filter(Boolean).join(" · ");
return `<div class="scan-remote">
<div class="scan-remote-head">
<span class="scan-remote-name">${escapeHtml(j.remote || "?")}</span>
<span class="scan-remote-status">${escapeHtml(j.status || "queued")}</span>
${pct != null ? `<span class="scan-remote-pct">${pct}%</span>` : ""}
</div>
<div class="scan-remote-detail">${escapeHtml(detail)}</div>
${pct != null ? `<div class="scan-track"><div class="scan-fill" style="width:${pct}%"></div></div>` : ""}
</div>`;
}).join("");
scanJobOut.innerHTML = `
<div class="scan-job-title">
<span>${escapeHtml(jobLabel)}</span>
${when ? `<span>${escapeHtml(when)}</span>` : ""}
</div>
<div class="scan-job-head">
<span class="scan-pill ${pillCls}">${escapeHtml(status)}</span>
<span class="scan-job-meta">${escapeHtml(meta || "scan job")}</span>
</div>
${metrics.length ? `<div class="scan-metrics">${metrics.map(([label, value]) => `
<span class="scan-metric"><span>${escapeHtml(label)}</span><b>${escapeHtml(value)}</b></span>
`).join("")}</div>` : ""}
${retiredRoots.length ? `<div class="section-note warn" style="margin:0 0 8px;">Historical scan roots not in current config: ${escapeHtml(retiredRoots.join(", "))}. They are shown because this job was recorded before the scan roots changed.</div>` : ""}
${r.error ? `<div style="color:#faa;margin-bottom:6px;">${escapeHtml(r.error)}</div>` : ""}
${jobRows || `<div style="color:#777;">waiting for remote progress...</div>`}
`;
}
const _pollOptProgress = () => {
chrome.runtime.sendMessage({ type: "scan-progress" }, (r) => {
if (chrome.runtime.lastError || !r || !r.ok) return;
_renderScanJob(r);
if (r.done || !r.scanning) {
_stopOptPoll();
_setOptScanningState(false);
if (r.cancelled) {
return;
} else if (r.scan_ok !== false) {
setTimeout(() => document.getElementById("cache-status-run").click(), 500);
}
return;
}
});
};
async function _refreshScanJob() {
try {
const cache = await chrome.runtime.sendMessage({ type: "cache-status" });
if (cache && cache.ok) rememberConfiguredScanRoots(cache);
} catch {}
_pollOptProgress();
}
async function _startOptScan(scanRoots = [], forceSince = null) {
const out = scanJobOut;
if (_optScanning) {
// Cancel in-progress scan
rebuildBtn.disabled = true;
rebuildBtn.textContent = "Cancelling…";
chrome.runtime.sendMessage({ type: "scan-cancel" }, () => {
rebuildBtn.disabled = false;
// State will update on next poll tick
});
return;
}
// forceSince overrides dropdown (used by per-remote Refresh to stay incremental)
const scanSince = forceSince !== null ? forceSince : (rebuildMode ? rebuildMode.value : "");
const scope = scanRoots.length ? `refresh ${scanRoots.join(", ")}` : "all configured scan roots";
const label = scanSince ? `incrementally update files changed in the last ${scanSince}` : "fully rebuild";
const button = scanRoots.length ? "Refresh" : "Rebuild";
if (!confirm(`${button} cache now?\n\nScope: ${scope}\nMode: ${label}\n\nThis can take several minutes.`)) return;
cacheStatusOut.innerHTML = `<span style="color:#6ec1ff;">starting scan…</span>`;
out.innerHTML = "";
try {
const r = await chrome.runtime.sendMessage({ type: "run-scan", scanSince, scanRoots });
if (!r || !r.ok) {
out.innerHTML = `<span style="color:#faa;">scan failed:</span> ${escapeHtml(r?.error || "no response")}`;
return;
}
_setOptScanningState(true);
_pollOptProgress();
_optScanTimer = setInterval(_pollOptProgress, 1500);
} catch (err) {
out.innerHTML = `<span style="color:#faa;">scan failed:</span> ${escapeHtml(err.message || String(err))}`;
}
}
rebuildBtn.addEventListener("click", () => _startOptScan());
function _renderNonJavPanel(items, remote) {
const panel = document.createElement("div");
panel.className = "nonjav-panel";
panel.dataset.remote = remote;
const deleteEnabled = document.getElementById("enableDelete")?.checked;
const delBtnHtml = deleteEnabled
? `<button class="nonjav-del-all" type="button" title="Delete all non-JAV files in this remote">Delete All (${items.length})</button>`
: `<span style="font-size:11px;color:#555;">Enable deletion in settings to delete</span>`;
panel.innerHTML = `
<div class="nonjav-panel-head">
<span class="nonjav-panel-title">${escapeHtml(remote)} · ${items.length} non-JAV file${items.length !== 1 ? "s" : ""}</span>
${delBtnHtml}
</div>
<div class="nonjav-list">${items.map(f => `
<div class="nonjav-item" data-full-path="${escapeHtml(f.full_path)}">
<span class="nonjav-ext">${escapeHtml(f.ext || "?")}</span>
<span class="nonjav-path" title="${escapeHtml(f.full_path)}">${escapeHtml(f.path)}</span>
${deleteEnabled ? `<button class="nonjav-del-one" type="button">Delete</button>` : ""}
</div>`).join("")}
</div>
<div class="nonjav-status"></div>`;
// Delete one
panel.addEventListener("click", async (e) => {
const btn = e.target.closest(".nonjav-del-one");
if (btn) {
const item = btn.closest(".nonjav-item");
const path = item?.dataset.fullPath;
if (!path) return;
if (!confirm(`Delete?\n${path}`)) return;
btn.disabled = true;
const r = await chrome.runtime.sendMessage({ type: "delete-skipped", paths: [path] });
if (r?.ok) {
item.classList.add("deleted");
item.querySelector(".nonjav-del-one")?.remove();
_updateNonJavDelAll(panel);
} else {
btn.disabled = false;
panel.querySelector(".nonjav-status").textContent = "Error: " + (r?.error || "failed");
}
return;
}
const delAll = e.target.closest(".nonjav-del-all");
if (delAll) {
const allItems = [...panel.querySelectorAll(".nonjav-item:not(.deleted)")];
const paths = allItems.map(i => i.dataset.fullPath).filter(Boolean);
if (!paths.length) return;
if (!confirm(`Delete all ${paths.length} non-JAV file(s) from ${remote}?`)) return;
delAll.disabled = true;
const statusEl = panel.querySelector(".nonjav-status");
statusEl.textContent = `Deleting ${paths.length} file(s)…`;
const r = await chrome.runtime.sendMessage({ type: "delete-skipped", paths });
const ok = r?.deleted_count || 0;
const fail = r?.failed_count || 0;
if (ok) {
// Mark successfully deleted items
const deletedPaths = new Set(
(r.results || []).filter(x => x.ok).map(x => x.path)
);
allItems.forEach(i => {
if (deletedPaths.has(i.dataset.fullPath)) {
i.classList.add("deleted");
i.querySelector(".nonjav-del-one")?.remove();
}
});
_updateNonJavDelAll(panel);
}
statusEl.textContent = fail
? `Deleted ${ok}, failed ${fail}. Check deletion settings.`
: `Deleted ${ok} file(s).`;
}
});
return panel;
}
function _updateNonJavDelAll(panel) {
const remaining = panel.querySelectorAll(".nonjav-item:not(.deleted)").length;
const btn = panel.querySelector(".nonjav-del-all");
if (btn) {
btn.textContent = `Delete All (${remaining})`;
btn.disabled = remaining === 0;
}
}
cacheStatusOut.addEventListener("click", (event) => {
const showSkipped = event.target.closest(".cache-show-skipped");
if (showSkipped) {
const remote = showSkipped.dataset.remote;
// Toggle: if panel already open, close it
const existing = cacheStatusOut.querySelector(`.nonjav-panel[data-remote="${CSS.escape(remote)}"]`);
if (existing) { existing.remove(); showSkipped.textContent = showSkipped.textContent.replace("▴", "▾"); return; }
showSkipped.textContent = showSkipped.textContent.replace("▾", "▴");
// Find skipped items from last cache status result
const items = (_cacheSkippedByRemote?.get(remote)) || [];
const panel = _renderNonJavPanel(items, remote);
// Insert after the row containing this button
showSkipped.closest("div")?.after(panel);
return;
}
const reextract = event.target.closest(".cache-reextract");
if (reextract) {
const original = reextract.textContent;
reextract.disabled = true;
reextract.textContent = "Re-extracting…";
(async () => {
try {
const r = await chrome.runtime.sendMessage({ type: "reextract-ids" });
if (!r || !r.ok) {
reextract.textContent = original;
reextract.disabled = false;
const note = document.createElement("div");
note.style.cssText = "color:#faa;margin-top:6px;font-size:11px;";
note.textContent = `Re-extract failed: ${r?.error || "no response"}`;
reextract.after(note);
return;
}
const note = document.createElement("div");
note.style.cssText = "color:#afa;margin-top:6px;font-size:11px;";
note.textContent = `Re-extracted ${r.total ?? 0} IDs · ${r.changed ?? 0} changed · ${r.unchanged ?? 0} unchanged · ${r.dropped ?? 0} dropped. Re-run Check Cache to refresh this view.`;
reextract.replaceWith(note);
} catch (err) {
reextract.textContent = original;
reextract.disabled = false;
const note = document.createElement("div");
note.style.cssText = "color:#faa;margin-top:6px;font-size:11px;";
note.textContent = `Re-extract failed: ${err?.message || String(err)}`;
reextract.after(note);
}
})();
return;
}
const refresh = event.target.closest(".cache-refresh-remote");
if (refresh) {
const remote = refresh.dataset.remote || "";
if (!remote) return;
// Per-remote Refresh is always incremental — inherit dropdown value if it's a
// duration (not "Full Rebuild"), otherwise default to 24h.
const dropdownVal = rebuildMode ? rebuildMode.value : "";
const refreshSince = dropdownVal || "24h";
_startOptScan([remote], refreshSince);
return;
}
});
document.getElementById("scan-job-clear").addEventListener("click", async () => {
if (!confirm("Clear recorded scan job history?\n\nThis only clears the Scan Job panel state. It does not change cache.json.")) return;
scanJobOut.textContent = "clearing scan job history...";
const r = await chrome.runtime.sendMessage({ type: "scan-clear" });
if (!r || !r.ok) {
scanJobOut.innerHTML = `<span style="color:#faa;">clear failed:</span> ${escapeHtml(r?.error || "no response")}`;
return;
}
_renderScanJob({ ok: true, no_state: true });
});
// If Options is opened while a scan is already running, attach to it instead
// of showing an idle Rebuild button.
_refreshScanJob();
chrome.runtime.sendMessage({ type: "scan-progress" }, (r) => {
if (chrome.runtime.lastError || !r || !r.ok) return;
_renderScanJob(r);
if (!r.scanning) return;
_setOptScanningState(true);
_optScanTimer = setInterval(_pollOptProgress, 1500);
});
})();