ad4df28a66
New files: bulk-check.html, bulk-check.js, bulk-check.css Popup gains a 📋 launcher button next to the ⚙ Options gear. Clicking it sends `open-bulk-check` to background.js and closes the popup; background.js owns window lifecycle: - chrome.storage.session.bulkCheckWindowId stashes the open window id - existing id → chrome.windows.update({ focused, drawAttention }) - missing or stale id → chrome.windows.create({ type:'popup', width:640, height:540 }) and stash the new id - chrome.windows.onRemoved clears the stale id on close Last-paste persisted to chrome.storage.local.bulkCheckLastPaste, debounced 500ms on input, restored on window open. quickMode is read from settings at run time, matching previous behavior. Ctrl/Cmd+Enter inside the textarea triggers the check. Options page no longer carries the Bulk ID Check fieldset: removed from options.html (Library Review pdesc updated to note the relocation) and the matching handlers from options.js (1903 → 1852 lines). No manifest permission changes — own-page chrome.windows.create needs no extra permission. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
664 lines
25 KiB
JavaScript
664 lines
25 KiB
JavaScript
const $status = document.getElementById("status");
|
|
const $output = document.getElementById("output");
|
|
const $deleteBtn = document.getElementById("delete-btn");
|
|
const $undoBtn = document.getElementById("undo-btn");
|
|
const $cacheBanner = document.getElementById("cache-banner");
|
|
const $filterBar = document.getElementById("filter-bar");
|
|
const $modeLive = document.getElementById("mode-live");
|
|
const $modeCache = document.getElementById("mode-cache");
|
|
const $pauseScan = document.getElementById("pause-scan");
|
|
|
|
let lastResult = null;
|
|
let settings = null;
|
|
let activeFilter = "all"; // "all" | "Source" | "Target" | "Catalog"
|
|
|
|
function syncModeToggle() {
|
|
const live = settings && settings.quickMode !== false;
|
|
$modeLive.classList.toggle("active", live);
|
|
$modeCache.classList.toggle("active", !live);
|
|
$modeLive.setAttribute("aria-pressed", live ? "true" : "false");
|
|
$modeCache.setAttribute("aria-pressed", !live ? "true" : "false");
|
|
}
|
|
|
|
function syncPauseButton() {
|
|
const paused = !!(settings && settings.scanPaused);
|
|
$pauseScan.classList.toggle("paused", paused);
|
|
$pauseScan.textContent = paused ? "▶" : "⏸";
|
|
$pauseScan.title = paused ? "Resume scanning" : "Pause scanning";
|
|
$pauseScan.setAttribute("aria-pressed", paused ? "true" : "false");
|
|
}
|
|
|
|
function renderPausedState() {
|
|
setStatus("Scanning paused", "err");
|
|
$output.innerHTML = "";
|
|
const div = document.createElement("div");
|
|
div.className = "empty";
|
|
div.textContent = "Press ▶ to resume scans.";
|
|
$output.appendChild(div);
|
|
$deleteBtn.style.display = "none";
|
|
$undoBtn.style.display = "none";
|
|
}
|
|
|
|
async function setScanPaused(paused) {
|
|
const s = await chrome.runtime.sendMessage({ type: "get-settings" }) || {};
|
|
settings = Object.assign({}, s, { scanPaused: paused });
|
|
await chrome.storage.sync.set({ settings });
|
|
chrome.runtime.sendMessage({ type: "settings-changed" });
|
|
syncPauseButton();
|
|
if (paused) renderPausedState();
|
|
else if (manualMode && $searchInput.value.trim()) runManualSearch();
|
|
else runCheck(true);
|
|
}
|
|
|
|
async function setSearchMode(mode) {
|
|
const quickMode = mode === "live";
|
|
if (settings && settings.quickMode === quickMode) return;
|
|
const s = await chrome.runtime.sendMessage({ type: "get-settings" }) || {};
|
|
settings = Object.assign({}, s, { quickMode });
|
|
await chrome.storage.sync.set({ settings });
|
|
chrome.runtime.sendMessage({ type: "settings-changed" });
|
|
syncModeToggle();
|
|
if (manualMode && $searchInput.value.trim()) runManualSearch();
|
|
else runCheck(true);
|
|
}
|
|
|
|
function setStatus(text, cls = "") {
|
|
$status.className = cls;
|
|
if (cls === "loading") {
|
|
$status.innerHTML = `<span class="spinner"></span><span>${text}</span>`;
|
|
} else {
|
|
$status.textContent = text;
|
|
}
|
|
}
|
|
|
|
function showSkeleton(rows = 2) {
|
|
$output.innerHTML = "";
|
|
for (let i = 0; i < rows; i++) {
|
|
const div = document.createElement("div");
|
|
div.className = "skeleton";
|
|
div.innerHTML = `
|
|
<div class="bar long"></div>
|
|
<div class="bar short"></div>
|
|
<div class="bar tiny"></div>
|
|
`;
|
|
$output.appendChild(div);
|
|
}
|
|
}
|
|
|
|
function fmtAge(ts) {
|
|
if (!ts) return "";
|
|
const s = Math.round((Date.now() - ts) / 1000);
|
|
if (s < 60) return `${s}s ago`;
|
|
if (s < 3600) return `${Math.round(s / 60)}m ago`;
|
|
return `${Math.round(s / 3600)}h ago`;
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
}
|
|
|
|
function fmtMs(n) {
|
|
return Number.isFinite(n) ? `${n}ms` : "?";
|
|
}
|
|
|
|
function renderTimings(r) {
|
|
const t = r && r.timings;
|
|
if (!t) return;
|
|
const div = document.createElement("div");
|
|
div.className = "timing-strip";
|
|
const engineLabel = Number.isFinite(t.host_cached_ms) ? "HOST" : "RC-JAV";
|
|
const engineMs = Number.isFinite(t.host_cached_ms) ? t.host_cached_ms : t.host_rcjav_ms;
|
|
const matchMs = t.cache_match_ms ?? t.match_ms;
|
|
const metrics = [
|
|
["TOTAL", fmtMs(t.total_ms)],
|
|
[engineLabel, fmtMs(engineMs)],
|
|
["EXTRACT", fmtMs(t.extract_ms)],
|
|
["MATCH", fmtMs(matchMs)],
|
|
];
|
|
div.innerHTML = metrics.map(([label, value]) => `
|
|
<div class="timing-metric">
|
|
<div class="timing-label">${escapeHtml(label)}</div>
|
|
<div class="timing-value">${escapeHtml(value)}</div>
|
|
</div>
|
|
`).join("");
|
|
$output.appendChild(div);
|
|
}
|
|
|
|
function renderSearchMode(_r) {
|
|
// Mode chip removed — the LIVE/CACHE toggle in the header already shows the mode.
|
|
}
|
|
|
|
function buildHitCard(h) {
|
|
const filename = h.path.split("/").pop();
|
|
const idx = h.full_path.lastIndexOf("/");
|
|
const dir = idx >= 0 ? h.full_path.slice(0, idx) : h.full_path;
|
|
const srcCls = h.source === "Source" ? "src source"
|
|
: h.source === "Catalog" ? "src catalog" : "src";
|
|
const confidence = h.match_confidence ? ` · ${h.match_confidence}` : "";
|
|
const reason = h.match_reason
|
|
? `<span class="reason" title="Matched ${escapeHtml(h.matched_query || h.jav_id || "")}${escapeHtml(confidence)}">${escapeHtml(h.match_reason)}</span>`
|
|
: "";
|
|
const div = document.createElement("div");
|
|
div.className = "hit";
|
|
div.innerHTML = `
|
|
<div class="file">${escapeHtml(filename)}</div>
|
|
<div class="path"><span class="plabel">Path:</span> ${escapeHtml(dir)}</div>
|
|
<div class="meta"><span class="${srcCls}">${escapeHtml(h.source.toUpperCase())}</span><span class="size">${escapeHtml(h.size_human)}</span>${reason}</div>
|
|
`;
|
|
return div;
|
|
}
|
|
|
|
function renderFilterBar(structured) {
|
|
$filterBar.innerHTML = "";
|
|
if (!structured || structured.length < 2) { $filterBar.style.display = "none"; return; }
|
|
const sources = [...new Set(structured.map((h) => h.source))];
|
|
if (sources.length < 2) { $filterBar.style.display = "none"; return; }
|
|
$filterBar.style.display = "";
|
|
const filters = ["all", ...sources];
|
|
for (const f of filters) {
|
|
const chip = document.createElement("span");
|
|
chip.className = "filter-chip" + (activeFilter === f ? " active" : "");
|
|
chip.textContent = f === "all" ? "All" : f;
|
|
chip.addEventListener("click", () => {
|
|
activeFilter = f;
|
|
renderHits(lastResult && lastResult.structured);
|
|
for (const c of $filterBar.children) c.classList.toggle("active", c.textContent === (f === "all" ? "All" : f));
|
|
});
|
|
$filterBar.appendChild(chip);
|
|
}
|
|
}
|
|
|
|
function renderHits(structured) {
|
|
// Clear existing hit cards but keep mode/timing lines
|
|
for (const el of [...$output.children]) {
|
|
if (el.classList.contains("hit") || el.classList.contains("empty")) el.remove();
|
|
}
|
|
const hits = structured || [];
|
|
const filtered = activeFilter === "all" ? hits : hits.filter((h) => h.source === activeFilter);
|
|
if (filtered.length) {
|
|
const anchor = $output.querySelector(".timing-strip") || $output.querySelector(".timings");
|
|
for (const h of filtered) {
|
|
const card = buildHitCard(h);
|
|
if (anchor) $output.insertBefore(card, anchor);
|
|
else $output.appendChild(card);
|
|
}
|
|
} else {
|
|
const div = document.createElement("div");
|
|
div.className = "empty";
|
|
div.textContent = filtered.length === 0 && activeFilter !== "all"
|
|
? `No ${activeFilter} results`
|
|
: "no matches";
|
|
const anchor = $output.querySelector(".timing-strip") || $output.querySelector(".timings");
|
|
if (anchor) $output.insertBefore(div, anchor);
|
|
else $output.appendChild(div);
|
|
}
|
|
}
|
|
|
|
function isNativeRegistrationIssue(result) {
|
|
return result && (result.error_kind === "forbidden" || result.error_kind === "not_found");
|
|
}
|
|
|
|
function renderNativeSetupGuide(result) {
|
|
const div = document.createElement("div");
|
|
div.className = "setup-guide";
|
|
const forbidden = result.error_kind === "forbidden";
|
|
div.innerHTML = `
|
|
<strong>${forbidden ? "Register this extension ID on this PC" : "Native host registration is missing on this PC"}</strong>
|
|
<div class="detail">${forbidden
|
|
? "Brave found the host, but this copy of the extension is not allowed to launch it yet."
|
|
: "Brave cannot find the rclone-jav native host yet."}</div>
|
|
<div class="actions">
|
|
<button type="button" data-open-native-setup>Open Registration Guide</button>
|
|
${result.extension_id ? `<button type="button" data-copy-extension-id="${escapeHtml(result.extension_id)}">Copy Extension ID</button>` : ""}
|
|
</div>
|
|
`;
|
|
div.querySelector("[data-open-native-setup]").addEventListener("click", async () => {
|
|
await chrome.storage.local.set({
|
|
optionsActivePane: "diagnostics",
|
|
pendingNativeSetupIssue: {
|
|
error: result.error || result.reason || "native host registration failed",
|
|
error_kind: result.error_kind,
|
|
extension_id: result.extension_id || "",
|
|
},
|
|
});
|
|
chrome.runtime.openOptionsPage();
|
|
});
|
|
const copy = div.querySelector("[data-copy-extension-id]");
|
|
if (copy) {
|
|
copy.addEventListener("click", async () => {
|
|
await navigator.clipboard.writeText(copy.dataset.copyExtensionId || "");
|
|
copy.textContent = "Copied";
|
|
setTimeout(() => { copy.textContent = "Copy Extension ID"; }, 1200);
|
|
});
|
|
}
|
|
$output.appendChild(div);
|
|
}
|
|
|
|
function renderResult(r) {
|
|
lastResult = r;
|
|
activeFilter = "all";
|
|
$output.innerHTML = "";
|
|
$filterBar.style.display = "none";
|
|
if (!r) { setStatus("no response", "err"); return; }
|
|
const tag = r.cached ? ` [session ${fmtAge(r.ts)}]` : "";
|
|
if (!r.ok) {
|
|
setStatus("✗ " + (r.reason || "error") + tag, "err");
|
|
$output.innerHTML = "";
|
|
if (r.no_match_title || r.no_match_detail) {
|
|
const div = document.createElement("div");
|
|
div.className = "empty no-match-detail";
|
|
div.innerHTML = `<strong>${escapeHtml(r.no_match_title || "No result")}</strong>${r.no_match_detail ? `<div>${escapeHtml(r.no_match_detail)}</div>` : ""}`;
|
|
$output.appendChild(div);
|
|
}
|
|
if (r.stderr || r.error) {
|
|
const div = document.createElement("div");
|
|
div.className = "err";
|
|
div.textContent = r.stderr || r.error;
|
|
$output.appendChild(div);
|
|
}
|
|
if (isNativeRegistrationIssue(r)) renderNativeSetupGuide(r);
|
|
$deleteBtn.style.display = "none";
|
|
$undoBtn.style.display = "none";
|
|
return;
|
|
}
|
|
const idShown = r.id || (r.structured && r.structured[0] && r.structured[0].jav_id) || "?";
|
|
if (r.hits > 0) setStatus(`✓ ${idShown} — ${r.hits} hit(s)${tag}`, "hit");
|
|
else setStatus(`✗ ${idShown} — NOT FOUND${tag}`, "miss");
|
|
|
|
// Render mode + timings first (renderHits inserts before these)
|
|
renderSearchMode(r);
|
|
renderTimings(r);
|
|
// Render filter bar then hit cards
|
|
renderFilterBar(r.structured);
|
|
if (r.structured && r.structured.length) {
|
|
renderHits(r.structured);
|
|
} else {
|
|
const div = document.createElement("div");
|
|
div.className = "empty no-match-detail";
|
|
div.innerHTML = `<strong>${escapeHtml(r.no_match_title || "No matches")}</strong>${r.no_match_detail ? `<div>${escapeHtml(r.no_match_detail)}</div>` : ""}`;
|
|
$output.insertBefore(div, $output.firstChild);
|
|
}
|
|
|
|
const canDelete = settings && settings.enableDelete && r.structured && r.structured.length > 1;
|
|
$deleteBtn.style.display = canDelete ? "" : "none";
|
|
$deleteBtn.textContent = "DELETE";
|
|
$undoBtn.style.display = (settings && settings.enableDelete && settings.deleteMode === "trash") ? "" : "none";
|
|
}
|
|
|
|
function runCheck(force = false) {
|
|
if (settings && settings.scanPaused) { renderPausedState(); return; }
|
|
setStatus("Scanning…", "loading");
|
|
showSkeleton(2);
|
|
$deleteBtn.style.display = "none";
|
|
$undoBtn.style.display = "none";
|
|
chrome.runtime.sendMessage({ type: "check-tab", force }, (r) => {
|
|
if (chrome.runtime.lastError) {
|
|
setStatus("error: " + chrome.runtime.lastError.message, "err");
|
|
$output.innerHTML = "";
|
|
return;
|
|
}
|
|
renderResult(r);
|
|
});
|
|
}
|
|
|
|
// ----- delete modal -----
|
|
|
|
const $overlay = document.getElementById("modal-overlay");
|
|
const $list = document.getElementById("modal-list");
|
|
const $confirmWrap = document.getElementById("modal-confirm");
|
|
const $target = document.getElementById("modal-target");
|
|
const $confirmId = document.getElementById("confirm-id");
|
|
const $confirmInput = document.getElementById("modal-confirm-input");
|
|
const $mode = document.getElementById("modal-mode");
|
|
const $modalDelete = document.getElementById("modal-delete");
|
|
const $modalStatus = document.getElementById("modal-status");
|
|
let chosenHit = null;
|
|
let expectedId = "";
|
|
|
|
function openDeleteModal() {
|
|
if (!lastResult || !lastResult.structured) return;
|
|
$list.innerHTML = "";
|
|
chosenHit = null;
|
|
$confirmWrap.style.display = "none";
|
|
$modalDelete.disabled = true;
|
|
$modalStatus.textContent = "";
|
|
$confirmInput.value = "";
|
|
|
|
for (const h of lastResult.structured) {
|
|
const filename = h.path.split("/").pop();
|
|
const idx = h.full_path.lastIndexOf("/");
|
|
const dir = idx >= 0 ? h.full_path.slice(0, idx) : h.full_path;
|
|
const srcCls = h.source === "Source" ? "src source"
|
|
: h.source === "Catalog" ? "src catalog" : "src";
|
|
const row = document.createElement("div");
|
|
row.className = "hit";
|
|
row.innerHTML = `
|
|
<div class="file">${escapeHtml(filename)}</div>
|
|
<div class="path"><span class="plabel">Path:</span> ${escapeHtml(dir)}</div>
|
|
<div class="meta"><span class="${srcCls}">${escapeHtml(h.source.toUpperCase())}</span><span class="size">${escapeHtml(h.size_human)}</span></div>
|
|
`;
|
|
row.addEventListener("click", () => selectHit(h, row));
|
|
$list.appendChild(row);
|
|
}
|
|
$overlay.style.display = "block";
|
|
}
|
|
|
|
function selectHit(h, row) {
|
|
chosenHit = h;
|
|
for (const el of $list.children) el.classList.remove("selected");
|
|
row.classList.add("selected");
|
|
$confirmWrap.style.display = "";
|
|
$target.textContent = h.full_path;
|
|
// Require typing the full filename (basename incl. extension) — strongest unique signal.
|
|
expectedId = h.path.split("/").pop();
|
|
$confirmId.textContent = expectedId;
|
|
const modeText = settings.deleteMode === "permanent"
|
|
? "PERMANENTLY DELETE"
|
|
: `move to trash: ${settings.trashDir}`;
|
|
$mode.innerHTML = `
|
|
<div><strong>Mode:</strong> ${escapeHtml(modeText)}</div>
|
|
<div><strong>Source:</strong> ${escapeHtml(h.source || "?")}</div>
|
|
<div><strong>Remote/prefix:</strong> ${escapeHtml(h.remote || "?")}</div>
|
|
<div><strong>Host safety:</strong> path must be inside configured rc-jav source/target or trash prefixes.</div>
|
|
`;
|
|
$confirmInput.value = "";
|
|
$confirmInput.focus();
|
|
$modalDelete.disabled = true;
|
|
}
|
|
|
|
$confirmInput.addEventListener("input", () => {
|
|
// Case-insensitive, exact filename match.
|
|
$modalDelete.disabled = $confirmInput.value.trim().toLowerCase() !== expectedId.toLowerCase();
|
|
});
|
|
|
|
document.getElementById("modal-cancel").addEventListener("click", () => {
|
|
$overlay.style.display = "none";
|
|
});
|
|
|
|
$modalDelete.addEventListener("click", () => {
|
|
if (!chosenHit) return;
|
|
$modalDelete.disabled = true;
|
|
$modalStatus.textContent = "deleting…";
|
|
chrome.runtime.sendMessage({ type: "delete-file", path: chosenHit.full_path }, (r) => {
|
|
if (!r) { $modalStatus.textContent = "no response"; return; }
|
|
if (!r.ok) { $modalStatus.textContent = "error: " + (r.error || r.stderr || "unknown"); return; }
|
|
$modalStatus.textContent = (settings.deleteMode === "permanent" ? "deleted" : "moved to: " + r.dst);
|
|
// Refresh underlying tab result
|
|
setTimeout(() => {
|
|
$overlay.style.display = "none";
|
|
runCheck(true);
|
|
}, 800);
|
|
});
|
|
});
|
|
|
|
// ----- search history -----
|
|
|
|
const HISTORY_KEY = "searchHistory";
|
|
const HISTORY_MAX = 20;
|
|
const $historyBar = document.getElementById("history-bar");
|
|
const $historyChips = document.getElementById("history-chips");
|
|
|
|
async function loadHistory() {
|
|
const got = await chrome.storage.local.get(HISTORY_KEY);
|
|
return Array.isArray(got[HISTORY_KEY]) ? got[HISTORY_KEY] : [];
|
|
}
|
|
|
|
async function pushHistory(id) {
|
|
const list = await loadHistory();
|
|
const filtered = list.filter((x) => x.toLowerCase() !== id.toLowerCase());
|
|
const updated = [id, ...filtered].slice(0, HISTORY_MAX);
|
|
await chrome.storage.local.set({ [HISTORY_KEY]: updated });
|
|
renderHistory(updated);
|
|
}
|
|
|
|
function renderHistory(list) {
|
|
$historyChips.innerHTML = "";
|
|
if (!list || !list.length) { $historyBar.style.display = "none"; return; }
|
|
$historyBar.style.display = "";
|
|
for (const id of list) {
|
|
const chip = document.createElement("span");
|
|
chip.className = "history-chip";
|
|
chip.textContent = id;
|
|
chip.title = `Search ${id}`;
|
|
chip.addEventListener("click", () => {
|
|
$searchInput.value = id;
|
|
runManualSearch();
|
|
});
|
|
$historyChips.appendChild(chip);
|
|
}
|
|
}
|
|
|
|
document.getElementById("history-clear").addEventListener("click", async () => {
|
|
await chrome.storage.local.set({ [HISTORY_KEY]: [] });
|
|
renderHistory([]);
|
|
});
|
|
|
|
// ----- manual search -----
|
|
|
|
const $searchInput = document.getElementById("search-input");
|
|
const $searchGo = document.getElementById("search-go");
|
|
const $searchClear = document.getElementById("search-clear");
|
|
let manualMode = false; // true while popup is showing manual-search results
|
|
|
|
function runManualSearch() {
|
|
const raw = $searchInput.value.trim();
|
|
if (!raw) return;
|
|
if (settings && settings.scanPaused) { renderPausedState(); return; }
|
|
manualMode = true;
|
|
setStatus(`Searching ${raw}…`, "loading");
|
|
showSkeleton(2);
|
|
$deleteBtn.style.display = "none";
|
|
$undoBtn.style.display = "none";
|
|
const t0 = performance.now();
|
|
chrome.runtime.sendMessage({
|
|
type: "manual-query",
|
|
action: "search",
|
|
id: raw,
|
|
quick: !!(settings && settings.quickMode),
|
|
}, (r) => {
|
|
if (chrome.runtime.lastError) {
|
|
setStatus("error: " + chrome.runtime.lastError.message, "err");
|
|
$output.innerHTML = "";
|
|
return;
|
|
}
|
|
// The host search response uses the same shape as check-tab, so reuse the
|
|
// renderer. Synthesize a `timings.total_ms` so the timing chip still works.
|
|
const result = Object.assign({}, r, {
|
|
id: raw,
|
|
timings: Object.assign({ total_ms: Math.round(performance.now() - t0) }, r && r.timings ? r.timings : {}),
|
|
});
|
|
renderResult(result);
|
|
if (result && result.ok) pushHistory(raw);
|
|
});
|
|
}
|
|
|
|
$searchGo.addEventListener("click", runManualSearch);
|
|
$searchInput.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") { e.preventDefault(); runManualSearch(); }
|
|
});
|
|
$searchClear.addEventListener("click", () => {
|
|
$searchInput.value = "";
|
|
manualMode = false;
|
|
runCheck(false);
|
|
$searchInput.focus();
|
|
});
|
|
|
|
// ----- undo modal -----
|
|
|
|
const $undoOverlay = document.getElementById("undo-overlay");
|
|
const $undoList = document.getElementById("undo-list");
|
|
const $undoStatus = document.getElementById("undo-status");
|
|
|
|
function openUndoModal() {
|
|
$undoList.innerHTML = `<div class="empty">Loading…</div>`;
|
|
$undoStatus.textContent = "";
|
|
$undoOverlay.style.display = "block";
|
|
chrome.runtime.sendMessage({ type: "recent-deletes", limit: 20 }, (r) => {
|
|
if (chrome.runtime.lastError) {
|
|
$undoList.innerHTML = `<div class="err">${escapeHtml(chrome.runtime.lastError.message)}</div>`;
|
|
return;
|
|
}
|
|
if (!r || !r.ok) {
|
|
$undoList.innerHTML = `<div class="err">${escapeHtml((r && r.error) || "unknown error")}</div>`;
|
|
return;
|
|
}
|
|
const entries = (r.entries || []);
|
|
if (!entries.length) {
|
|
$undoList.innerHTML = `<div class="empty">No recent trash deletes found.</div>`;
|
|
return;
|
|
}
|
|
$undoList.innerHTML = "";
|
|
for (const e of entries) {
|
|
const origName = (e.path || "").split("/").pop();
|
|
const age = fmtAge(e.ts ? new Date(e.ts).getTime() : null);
|
|
const row = document.createElement("div");
|
|
row.className = "undo-entry";
|
|
row.innerHTML = `
|
|
<div class="file">${escapeHtml(origName)}</div>
|
|
<div class="path"><span class="plabel">Orig:</span> ${escapeHtml(e.path || "?")}</div>
|
|
<div class="path"><span class="plabel">Trash:</span> ${escapeHtml(e.dst || "?")}</div>
|
|
<div class="undo-meta"><span class="undo-age">${escapeHtml(age)}</span><button class="undo-row-btn">↶ Restore</button></div>
|
|
`;
|
|
row.querySelector(".undo-row-btn").addEventListener("click", () => doUndo(e, row));
|
|
$undoList.appendChild(row);
|
|
}
|
|
});
|
|
}
|
|
|
|
function doUndo(e, row) {
|
|
const btn = row.querySelector(".undo-row-btn");
|
|
btn.disabled = true;
|
|
btn.textContent = "…";
|
|
$undoStatus.textContent = "";
|
|
chrome.runtime.sendMessage({ type: "undo-delete", dst: e.dst, path: e.path }, (r) => {
|
|
if (!r) { btn.disabled = false; btn.textContent = "↶ Restore"; $undoStatus.textContent = "no response"; return; }
|
|
if (!r.ok) { btn.disabled = false; btn.textContent = "↶ Restore"; $undoStatus.textContent = "error: " + (r.error || r.stderr || "unknown"); return; }
|
|
btn.textContent = "✓ restored";
|
|
row.style.opacity = "0.5";
|
|
$undoStatus.textContent = "Restored to: " + (e.path || "?");
|
|
// Refresh the main result after a short pause
|
|
setTimeout(() => {
|
|
$undoOverlay.style.display = "none";
|
|
runCheck(true);
|
|
}, 1200);
|
|
});
|
|
}
|
|
|
|
document.getElementById("undo-cancel").addEventListener("click", () => {
|
|
$undoOverlay.style.display = "none";
|
|
});
|
|
$undoBtn.addEventListener("click", openUndoModal);
|
|
|
|
// ----- wiring -----
|
|
|
|
document.getElementById("recheck").addEventListener("click", async () => {
|
|
if (settings && settings.scanPaused) {
|
|
await setScanPaused(false);
|
|
return;
|
|
}
|
|
if (manualMode && $searchInput.value.trim()) runManualSearch();
|
|
else runCheck(true);
|
|
});
|
|
document.getElementById("open-options").addEventListener("click", () => chrome.runtime.openOptionsPage());
|
|
document.getElementById("open-bulk").addEventListener("click", () => {
|
|
chrome.runtime.sendMessage({ type: "open-bulk-check" });
|
|
window.close();
|
|
});
|
|
$modeLive.addEventListener("click", () => setSearchMode("live"));
|
|
$modeCache.addEventListener("click", () => setSearchMode("cache"));
|
|
$pauseScan.addEventListener("click", () => setScanPaused(!(settings && settings.scanPaused)));
|
|
$deleteBtn.addEventListener("click", openDeleteModal);
|
|
document.getElementById("ping").addEventListener("click", () => {
|
|
setStatus("pinging host…");
|
|
$output.textContent = "";
|
|
chrome.runtime.sendMessage({ type: "ping-host" }, (r) => {
|
|
if (!r || !r.ok) { setStatus("host unreachable", "err"); $output.textContent = r?.error || ""; return; }
|
|
setStatus("host ok: " + (r.version || "unknown"), "hit");
|
|
const rows = [
|
|
["Host version", r.version || "?"],
|
|
["rc-jav path", r.rc_jav || "?"],
|
|
["Script exists", r.rc_jav_exists ? "✓ yes" : "✗ not found"],
|
|
["Path source", r.rc_jav_overridden ? "options override" : "built-in default"],
|
|
["Python", r.python || "?"],
|
|
];
|
|
const div = document.createElement("div");
|
|
div.style.cssText = "font-size:12px;line-height:1.7;";
|
|
div.innerHTML = rows.map(([k, v]) =>
|
|
`<div><span style="color:#888;min-width:110px;display:inline-block;">${k}</span> <span style="color:#ddd;">${v}</span></div>`
|
|
).join("");
|
|
$output.appendChild(div);
|
|
});
|
|
});
|
|
|
|
function loadCacheBanner() {
|
|
chrome.runtime.sendMessage({ type: "cache-status" }, (r) => {
|
|
if (chrome.runtime.lastError || !r || !r.ok) return; // silent fail — banner optional
|
|
if (!r.cache_exists) {
|
|
$cacheBanner.className = "no-cache";
|
|
$cacheBanner.textContent = "⚠ No cache — searches use quick (live) mode only. Run --scan to build.";
|
|
$cacheBanner.style.display = "";
|
|
return;
|
|
}
|
|
const staleRemotes = (r.remotes || []).filter((x) => x.stale);
|
|
if (!staleRemotes.length) { $cacheBanner.style.display = "none"; return; }
|
|
const oldest = staleRemotes.reduce((a, b) => (b.age_hours || 0) > (a.age_hours || 0) ? b : a, staleRemotes[0]);
|
|
const ageH = oldest.age_hours != null ? Math.round(oldest.age_hours) : "?";
|
|
$cacheBanner.className = "";
|
|
$cacheBanner.textContent = `⚠ Cache is ${ageH}h old (${staleRemotes.length} remote${staleRemotes.length > 1 ? "s" : ""} stale). Consider running --scan.`;
|
|
$cacheBanner.style.display = "";
|
|
});
|
|
}
|
|
|
|
const $profileSelect = document.getElementById("profile-select");
|
|
$profileSelect.addEventListener("change", async () => {
|
|
const newProfile = $profileSelect.value;
|
|
// Save activeProfile to storage so background.js picks it up on next search.
|
|
const s = await chrome.runtime.sendMessage({ type: "get-settings" });
|
|
await chrome.storage.sync.set({ settings: Object.assign({}, s, { activeProfile: newProfile }) });
|
|
settings = Object.assign({}, s, { activeProfile: newProfile });
|
|
// Re-run current search with new profile
|
|
if (manualMode && $searchInput.value.trim()) runManualSearch();
|
|
else runCheck(true);
|
|
});
|
|
|
|
function renderProfileSelector(s) {
|
|
const profiles = s.profiles || [];
|
|
if (!profiles.length) { $profileSelect.style.display = "none"; return; }
|
|
$profileSelect.innerHTML = "";
|
|
const def = document.createElement("option");
|
|
def.value = "";
|
|
def.textContent = "Default";
|
|
$profileSelect.appendChild(def);
|
|
for (const p of profiles) {
|
|
const opt = document.createElement("option");
|
|
opt.value = p.name;
|
|
opt.textContent = p.name;
|
|
$profileSelect.appendChild(opt);
|
|
}
|
|
$profileSelect.value = s.activeProfile || "";
|
|
$profileSelect.style.display = "";
|
|
}
|
|
|
|
(async () => {
|
|
settings = await chrome.runtime.sendMessage({ type: "get-settings" }) || {};
|
|
syncModeToggle();
|
|
syncPauseButton();
|
|
renderProfileSelector(settings);
|
|
loadCacheBanner();
|
|
renderHistory(await loadHistory());
|
|
if (settings.scanPaused) {
|
|
renderPausedState();
|
|
return;
|
|
}
|
|
if (settings.triggers?.toolbarClick !== false) {
|
|
runCheck(false);
|
|
} else {
|
|
setStatus("toolbar auto-check disabled");
|
|
$output.innerHTML = "";
|
|
const div = document.createElement("div");
|
|
div.className = "empty";
|
|
div.textContent = "Use Re-Scan to check this page.";
|
|
$output.appendChild(div);
|
|
}
|
|
})();
|