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
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
// ---------- 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("");
|
||||
})
|
||||
);
|
||||
|
||||
// Three-state UX (docs/CACHE_CONTRACT.md): fresh / stale_by_rules / schema_mismatch.
|
||||
// Renders an inline banner above the per-remote list. Stale_by_rules adds a
|
||||
// "Re-extract IDs" button that triggers the fast rebuild without rclone.
|
||||
function renderCacheContractBanner(r) {
|
||||
const state = r.cache_state;
|
||||
if (r.rules_info_error) {
|
||||
return `<div style="margin-top:10px;padding:6px 8px;background:rgba(255,200,50,.08);border:1px solid rgba(255,200,50,.25);border-radius:4px;color:#ffa;">⚠ rules lookup failed: ${escapeHtml(r.rules_info_error)}</div>`;
|
||||
}
|
||||
if (state === "fresh") {
|
||||
return `<div style="margin-top:10px;padding:6px 8px;background:rgba(120,200,120,.08);border:1px solid rgba(120,200,120,.25);border-radius:4px;color:#afa;">✓ Cache up to date with current ID rules.</div>`;
|
||||
}
|
||||
if (state === "stale_by_rules") {
|
||||
const sigLine = r.id_rules_signature && r.id_rules_signature !== "legacy"
|
||||
? `<div style="color:#999;font-size:11px;margin-top:3px;">Cache signature: <code>${escapeHtml(String(r.id_rules_signature).slice(0, 22))}…</code></div>`
|
||||
: `<div style="color:#999;font-size:11px;margin-top:3px;">Cache predates the two-tier contract (legacy header).</div>`;
|
||||
return `<div style="margin-top:10px;padding:8px 10px;background:rgba(255,200,50,.08);border:1px solid rgba(255,200,50,.3);border-radius:4px;color:#ffa;">
|
||||
! <strong>Cache is stale by rules.</strong> ID extraction rules have changed since this cache was built. Some <code>jav_id</code> values may be out of date.
|
||||
${sigLine}
|
||||
<div style="margin-top:8px;"><button class="chip-btn cache-reextract" type="button" style="color:#ffd97a;background:rgba(255,200,50,.12);border-color:rgba(255,200,50,.35);font-weight:600;">Re-extract IDs (fast, no rescan)</button></div>
|
||||
</div>`;
|
||||
}
|
||||
if (state === "schema_mismatch") {
|
||||
return `<div style="margin-top:10px;padding:8px 10px;background:rgba(255,120,120,.08);border:1px solid rgba(255,120,120,.3);border-radius:4px;color:#faa;">
|
||||
✗ <strong>Cache schema mismatch.</strong> The on-disk cache shape is incompatible (schema ${escapeHtml(r.cache_schema ?? "?")} vs expected ${escapeHtml(r.expected_cache_schema ?? "?")}). A full re-scan is required.
|
||||
</div>`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
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 || []]));
|
||||
try {
|
||||
const ages = (r.remotes || [])
|
||||
.filter((m) => m.status !== "never_scanned" && Number.isFinite(Number(m.age_hours)))
|
||||
.map((m) => Number(m.age_hours));
|
||||
const minAge = ages.length ? Math.min(...ages) : null;
|
||||
chrome.storage.local.set({
|
||||
badge_cache_age_hours: minAge,
|
||||
badge_cache_stale_hours: Number(r.stale_hours) || 24,
|
||||
});
|
||||
} catch {}
|
||||
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>`,
|
||||
];
|
||||
rows.push(renderCacheContractBanner(r));
|
||||
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,343 @@
|
||||
// ---------- diagnostics ----------
|
||||
|
||||
// Extension ID display + copy button (added when Transfer Assistant was deleted).
|
||||
// Diagnostics is the canonical home for "what's my extension ID?" info now.
|
||||
(() => {
|
||||
const idEl = document.getElementById("diag-extension-id");
|
||||
const copyBtn = document.getElementById("diag-copy-extension-id");
|
||||
if (idEl) idEl.textContent = chrome.runtime.id;
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(chrome.runtime.id);
|
||||
copyBtn.textContent = "Copied";
|
||||
setTimeout(() => { copyBtn.textContent = "Copy ID"; }, 1200);
|
||||
} catch (_) {
|
||||
copyBtn.textContent = "Copy failed";
|
||||
setTimeout(() => { copyBtn.textContent = "Copy ID"; }, 1200);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
document.getElementById("run-diag").addEventListener("click", (event) =>
|
||||
keepActionViewport(event.currentTarget, runDiagnostics)
|
||||
);
|
||||
|
||||
// ---------- native messaging RPC log ----------
|
||||
const NATIVE_LOG_KEY = "rclonejavNativeLog";
|
||||
|
||||
function _fmtNativeLogTime(ts) {
|
||||
if (!Number.isFinite(ts)) return "?";
|
||||
const d = new Date(ts);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
function _fmtBytes(n) {
|
||||
if (!Number.isFinite(n) || n < 0) return "?";
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
|
||||
return `${(n / 1024 / 1024).toFixed(2)} MiB`;
|
||||
}
|
||||
|
||||
async function renderNativeLog() {
|
||||
const out = document.getElementById("native-log-results");
|
||||
if (!out) return;
|
||||
const errOnly = document.getElementById("native-log-errors-only")?.checked;
|
||||
let entries = [];
|
||||
try {
|
||||
const got = await chrome.storage.local.get(NATIVE_LOG_KEY);
|
||||
entries = Array.isArray(got[NATIVE_LOG_KEY]) ? got[NATIVE_LOG_KEY] : [];
|
||||
} catch (e) {
|
||||
out.innerHTML = `<span style="color:#faa;">error reading log:</span> ${escapeHtml(e.message || String(e))}`;
|
||||
return;
|
||||
}
|
||||
if (errOnly) entries = entries.filter((e) => !e.ok);
|
||||
if (!entries.length) {
|
||||
out.innerHTML = `<span style="color:#777;">${errOnly ? "no errors recorded" : "no RPC calls recorded yet"}</span>`;
|
||||
return;
|
||||
}
|
||||
out.innerHTML = entries.slice(0, 80).map((e) => {
|
||||
const ok = !!e.ok;
|
||||
const color = ok ? "#9be3b3" : "#ff9097";
|
||||
const action = e.action || "?";
|
||||
const latency = Number.isFinite(e.latency_ms) ? `${e.latency_ms}ms` : "?";
|
||||
const size = e.resp_bytes != null ? ` · ${_fmtBytes(e.resp_bytes)}` : "";
|
||||
const truncated = e.truncated ? ` · <span style="color:#ffd784;">TRUNCATED${e.truncated_reason ? " (" + escapeHtml(e.truncated_reason) + ")" : ""}</span>` : "";
|
||||
const inflight = e.inflight != null ? ` · ${e.inflight} inflight` : "";
|
||||
const head = `<div><span style="color:#888;">${escapeHtml(_fmtNativeLogTime(e.ts))}</span> <span style="color:${color};">${ok ? "✓" : "✗"} ${escapeHtml(action)}</span> · ${escapeHtml(latency)}${size}${truncated}${inflight}</div>`;
|
||||
const tail = !ok
|
||||
? `<div style="color:#aaa;margin-left:14px;"><span style="color:#888;">${escapeHtml(e.error_kind || "error")}:</span> ${escapeHtml(e.error || "")}</div>`
|
||||
: "";
|
||||
return `<div class="activity-entry">${head}${tail}</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
document.getElementById("native-log-run")?.addEventListener("click", renderNativeLog);
|
||||
document.getElementById("native-log-errors-only")?.addEventListener("change", renderNativeLog);
|
||||
document.getElementById("native-log-clear")?.addEventListener("click", async () => {
|
||||
if (!confirm("Clear extension-side native messaging log?")) return;
|
||||
await chrome.storage.local.remove(NATIVE_LOG_KEY);
|
||||
renderNativeLog();
|
||||
});
|
||||
document.getElementById("host-events-clear")?.addEventListener("click", async (e) => {
|
||||
if (!confirm("Truncate host/logs/rcjav-host-events.log on disk?")) return;
|
||||
const btn = e.currentTarget;
|
||||
const original = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Clearing…";
|
||||
try {
|
||||
const r = await chrome.runtime.sendMessage({ type: "clear-events-log" });
|
||||
btn.textContent = r?.ok ? "Cleared" : `Failed: ${r?.error || "no response"}`;
|
||||
} catch (err) {
|
||||
btn.textContent = `Failed: ${err.message || err}`;
|
||||
} finally {
|
||||
setTimeout(() => { btn.disabled = false; btn.textContent = original; }, 1500);
|
||||
}
|
||||
});
|
||||
// Live update if SW writes new entries while the page is open.
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area === "local" && NATIVE_LOG_KEY in changes) renderNativeLog();
|
||||
});
|
||||
|
||||
document.getElementById("host-status-run").addEventListener("click", (event) =>
|
||||
keepActionViewport(event.currentTarget, runHostStatus)
|
||||
);
|
||||
document.getElementById("host-repair-run").addEventListener("click", (event) =>
|
||||
keepActionViewport(event.currentTarget, runHostRepair)
|
||||
);
|
||||
document.getElementById("host-verify-run").addEventListener("click", (event) =>
|
||||
keepActionViewport(event.currentTarget, runHostStatus)
|
||||
);
|
||||
document.getElementById("run-all-diag").addEventListener("click", (event) =>
|
||||
keepActionViewport(event.currentTarget, async () => {
|
||||
clearNativeRepairCard();
|
||||
const runtime = await runDiagnostics();
|
||||
if (runtime && runtime.nativeBlocked) {
|
||||
renderBlockedByNativeIssue(document.getElementById("host-status-results"), "Host registration");
|
||||
return;
|
||||
}
|
||||
await runHostStatus();
|
||||
})
|
||||
);
|
||||
|
||||
function renderDiagRows(out, checks, emptyLabel) {
|
||||
out.innerHTML = "";
|
||||
if (!checks || checks.length === 0) {
|
||||
out.innerHTML = `<div class="diag-row warn"><span class="icon">!</span><span class="name">${escapeHtml(emptyLabel)}</span><span class="detail">no checks returned</span></div>`;
|
||||
return;
|
||||
}
|
||||
const counts = checks.reduce((acc, c) => {
|
||||
const status = c.status || "warn";
|
||||
acc[status] = (acc[status] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const summary = document.createElement("div");
|
||||
summary.className = "diag-row " + ((counts.fail || 0) ? "fail" : (counts.warn || 0) ? "warn" : "ok");
|
||||
summary.innerHTML = `<span class="icon">#</span><span class="name">summary</span><span class="detail">${checks.length} checks · ok ${counts.ok || 0} · info ${counts.info || 0} · warn ${counts.warn || 0} · fail ${counts.fail || 0}</span>`;
|
||||
out.appendChild(summary);
|
||||
for (const c of checks) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "diag-row " + (c.status || "warn");
|
||||
const status = c.status || "warn";
|
||||
const icon = status === "ok" ? "✓" : status === "info" ? "i" : status === "warn" ? "!" : "✗";
|
||||
row.innerHTML = `<span class="icon">${icon}</span><span class="name">${escapeHtml(c.name)}</span><span class="detail">${formatDiagDetail(c.detail || "")}</span>`;
|
||||
out.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDiagDetail(detail) {
|
||||
const text = String(detail || "");
|
||||
if (!text) return "";
|
||||
const shouldCollapse = text.length > 120 || text.includes("\n") || (text.match(/[;|]/g) || []).length > 2;
|
||||
if (!shouldCollapse) return escapeHtml(text);
|
||||
const first = text.split(/\r?\n/)[0].slice(0, 110);
|
||||
return `<details><summary>${escapeHtml(first)}${text.length > first.length ? "…" : ""}</summary><pre>${escapeHtml(text)}</pre></details>`;
|
||||
}
|
||||
|
||||
async function runDiagnostics() {
|
||||
const out = document.getElementById("diag-results");
|
||||
clearNativeRepairCard();
|
||||
out.innerHTML = '<div class="diag-row warn"><span class="icon">…</span><span class="name">running…</span><span class="detail">waiting for native host</span></div>';
|
||||
try {
|
||||
const r = await chrome.runtime.sendMessage({ type: "diagnostics" });
|
||||
if (!r || !r.ok) {
|
||||
await renderNativeMessagingFailure(r);
|
||||
renderBlockedByNativeIssue(out, "Runtime diagnostics");
|
||||
return { nativeBlocked: true };
|
||||
}
|
||||
renderDiagRows(out, r.checks || [], "runtime");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
out.innerHTML = `<div class="diag-row fail"><span class="icon">✗</span><span class="name">runtime</span><span class="detail">${escapeHtml(err.message || String(err))}</span></div>`;
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function runHostStatus() {
|
||||
const out = document.getElementById("host-status-results");
|
||||
clearNativeRepairCard();
|
||||
out.innerHTML = '<div class="diag-row warn"><span class="icon">…</span><span class="name">checking…</span><span class="detail">reading manifest and registry state</span></div>';
|
||||
try {
|
||||
const r = await chrome.runtime.sendMessage({ type: "host-status" });
|
||||
if (!r || !r.ok) {
|
||||
await renderNativeMessagingFailure(r);
|
||||
renderBlockedByNativeIssue(out, "Native host checks");
|
||||
return { nativeBlocked: true };
|
||||
}
|
||||
renderDiagRows(out, r.checks || [], "host status");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
out.innerHTML = `<div class="diag-row fail"><span class="icon">✗</span><span class="name">host status</span><span class="detail">${escapeHtml(err.message || String(err))}</span></div>`;
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function runHostRepair() {
|
||||
const out = document.getElementById("host-status-results");
|
||||
clearNativeRepairCard();
|
||||
out.innerHTML = '<div class="diag-row warn"><span class="icon">…</span><span class="name">repairing…</span><span class="detail">launching install-host.ps1 (UAC prompt will appear)</span></div>';
|
||||
try {
|
||||
const r = await chrome.runtime.sendMessage({ type: "repair-host" });
|
||||
if (!r || !r.ok) {
|
||||
if (r?.error_kind) {
|
||||
await renderNativeMessagingFailure(r);
|
||||
renderBlockedByNativeIssue(out, "Registration repair");
|
||||
} else {
|
||||
out.innerHTML = `<div class="diag-row fail"><span class="icon">✗</span><span class="name">Registration repair</span><span class="detail">${escapeHtml(r?.error || "repair failed")}</span></div>`;
|
||||
}
|
||||
return { ok: false };
|
||||
}
|
||||
out.innerHTML = "";
|
||||
renderCompletedNativeRepair(r);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
out.innerHTML = `<div class="diag-row fail"><span class="icon">✗</span><span class="name">Registration repair</span><span class="detail">${escapeHtml(err.message || String(err))}</span></div>`;
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function clearNativeRepairCard() {
|
||||
const card = document.getElementById("native-repair-card");
|
||||
const out = document.getElementById("native-repair-results");
|
||||
const title = document.getElementById("native-repair-title");
|
||||
if (card) card.style.display = "none";
|
||||
if (out) out.innerHTML = "";
|
||||
if (title) title.textContent = "Native host setup";
|
||||
}
|
||||
|
||||
function renderCompletedNativeRepair(response) {
|
||||
const card = document.getElementById("native-repair-card");
|
||||
const out = document.getElementById("native-repair-results");
|
||||
if (!card || !out) return;
|
||||
card.style.display = "";
|
||||
const title = document.getElementById("native-repair-title");
|
||||
if (title) title.textContent = "install-host.ps1 launched";
|
||||
out.innerHTML = `
|
||||
<div class="diag-row ok"><span class="icon">✓</span><span class="name">Launcher started</span><span class="detail">${escapeHtml(response.message || "install-host.ps1 launched")}</span></div>
|
||||
<div class="diag-row info"><span class="icon">i</span><span class="name">Script</span><span class="detail">${escapeHtml(response.script_path || "")}</span></div>
|
||||
<div class="diag-row info"><span class="icon">i</span><span class="name">Manifest target</span><span class="detail">${escapeHtml(response.manifest_path || "")}</span></div>
|
||||
<div class="diag-row warn"><span class="icon">!</span><span class="name">Next steps</span><span class="detail">1) Approve the UAC prompt. 2) Wait for the PowerShell window to print "Press Enter to close" and press Enter. 3) Fully close and reopen Brave. 4) Click Verify Registration.</span></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderBlockedByNativeIssue(out, title) {
|
||||
out.innerHTML = `<div class="diag-row info"><span class="icon">i</span><span class="name">${escapeHtml(title)}</span><span class="detail">Blocked until this PC registers the native host for the current extension ID. Use the setup card above.</span></div>`;
|
||||
}
|
||||
|
||||
async function getPackagedHostPaths() {
|
||||
try {
|
||||
const resp = await fetch(chrome.runtime.getURL("host/com.rcjav.host.json"));
|
||||
if (!resp.ok) return {};
|
||||
const manifest = await resp.json();
|
||||
const bat = manifest.path || "";
|
||||
const hostDir = bat.replace(/[\\/][^\\/]+$/, "");
|
||||
return {
|
||||
hostBat: bat,
|
||||
hostDir,
|
||||
registerBat: hostDir ? hostDir + "\\register-host.bat" : "",
|
||||
installPs1: hostDir ? hostDir + "\\install-host.ps1" : "",
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function renderNativeMessagingFailure(response) {
|
||||
const card = document.getElementById("native-repair-card");
|
||||
const out = document.getElementById("native-repair-results");
|
||||
if (!card || !out) return;
|
||||
card.style.display = "";
|
||||
const title = document.getElementById("native-repair-title");
|
||||
if (title) title.textContent = "Register host on this PC";
|
||||
const error = response?.error || "no response";
|
||||
const kind = response?.error_kind || (/forbidden/i.test(error) ? "forbidden" : "unknown");
|
||||
const extensionId = response?.extension_id || chrome.runtime.id;
|
||||
const paths = await getPackagedHostPaths();
|
||||
const installCommand = paths.installPs1
|
||||
? `pwsh -ExecutionPolicy Bypass -File "${paths.installPs1}"`
|
||||
: `pwsh -ExecutionPolicy Bypass -File ".\\host\\install-host.ps1"`;
|
||||
const registerCommand = paths.registerBat ? `"${paths.registerBat}"` : ".\\host\\register-host.bat";
|
||||
const hostDir = paths.hostDir || "";
|
||||
const hostFolderUrl = hostDir ? "file:///" + hostDir.replace(/\\/g, "/").replace(/^([A-Za-z]:)/, "$1") : "";
|
||||
let cause = "This extension cannot launch the native messaging host yet.";
|
||||
let fix = "Run register-host.bat once on this PC, fully restart Brave, then verify registration.";
|
||||
if (kind === "forbidden") {
|
||||
cause = "Brave found the native host but the extension ID is not in its allowlist on this PC.";
|
||||
fix = "Run register-host.bat to refresh the manifest from allowed-extension-ids.json.";
|
||||
} else if (kind === "not_found") {
|
||||
cause = "Brave could not find a registered native messaging host for com.rcjav.host on this PC.";
|
||||
fix = "Run register-host.bat from the extension host folder.";
|
||||
} else if (kind === "disconnected") {
|
||||
cause = "The native host started and then disconnected or crashed.";
|
||||
fix = "After registration is fixed, run Runtime diagnostics again to check Python, rc-jav, and rclone.";
|
||||
} else if (kind === "timeout") {
|
||||
cause = "The native host did not respond before the timeout.";
|
||||
fix = "Restart Brave and check whether a scan or rclone command is stuck.";
|
||||
}
|
||||
const openFolderBtn = hostFolderUrl
|
||||
? `<button type="button" data-open-folder="${escapeHtml(hostFolderUrl)}" data-folder-path="${escapeHtml(hostDir)}">Open Host Folder</button>`
|
||||
: "";
|
||||
out.innerHTML = `
|
||||
<div class="diag-row warn"><span class="icon">!</span><span class="name">Setup required</span><span class="detail">Native host registration must be fixed before cache, runtime, and host checks can run.</span></div>
|
||||
<div class="diag-row warn"><span class="icon">!</span><span class="name">Likely cause</span><span class="detail">${escapeHtml(cause)}</span></div>
|
||||
<div class="diag-row info"><span class="icon">i</span><span class="name">Host message</span><span class="detail">${escapeHtml(error)}</span></div>
|
||||
<div class="diag-row ok"><span class="icon">→</span><span class="name">Fix on this PC</span><span class="detail">${escapeHtml(fix)}</span></div>
|
||||
<div class="diag-row info"><span class="icon">1</span><span class="name">Run register-host</span><span class="detail">
|
||||
<details open><summary>${escapeHtml(registerCommand)}</summary><pre>${escapeHtml(`Double-click ${registerCommand}\nor run the PowerShell alternative:\n${installCommand}\n\nThe script reads the extension ID from allowed-extension-ids.json — no paste step.`)}</pre></details>
|
||||
<span class="diag-action">${openFolderBtn}<button type="button" data-copy="${escapeHtml(hostDir)}" data-copy-label="Copy Folder Path">Copy Folder Path</button><button type="button" data-copy="${escapeHtml(registerCommand)}" data-copy-label="Copy Script Path">Copy Script Path</button><button type="button" data-copy="${escapeHtml(installCommand)}" data-copy-label="Copy PowerShell Alternative">Copy PowerShell Alternative</button></span>
|
||||
</span></div>
|
||||
<div class="diag-row info"><span class="icon">2</span><span class="name">Restart Brave</span><span class="detail">Close every Brave window/process, reopen Brave, then reload the extension.</span></div>
|
||||
<div class="diag-row info"><span class="icon">3</span><span class="name">Verify</span><span class="detail"><span class="diag-action"><button type="button" data-verify-registration>Verify Registration</button></span></span></div>
|
||||
`;
|
||||
for (const btn of out.querySelectorAll("button[data-copy]")) {
|
||||
btn.addEventListener("click", async () => {
|
||||
await navigator.clipboard.writeText(btn.dataset.copy || "");
|
||||
btn.textContent = "Copied";
|
||||
setTimeout(() => { btn.textContent = btn.dataset.copyLabel || "Copy"; }, 1200);
|
||||
});
|
||||
}
|
||||
for (const btn of out.querySelectorAll("button[data-open-folder]")) {
|
||||
btn.addEventListener("click", async () => {
|
||||
const url = btn.dataset.openFolder;
|
||||
const folderPath = btn.dataset.folderPath || "";
|
||||
try {
|
||||
// file:// URLs require "Allow access to file URLs" toggled on for the
|
||||
// extension. If Brave silently blocks it (no tab opens), fall back to
|
||||
// clipboard so the user can paste into File Explorer (Win+E).
|
||||
await chrome.tabs.create({ url });
|
||||
btn.textContent = "Opening…";
|
||||
setTimeout(() => { btn.textContent = "Open Host Folder"; }, 1500);
|
||||
} catch (_) {
|
||||
try { await navigator.clipboard.writeText(folderPath); } catch {}
|
||||
btn.textContent = "Blocked — path copied";
|
||||
setTimeout(() => { btn.textContent = "Open Host Folder"; }, 2500);
|
||||
}
|
||||
});
|
||||
}
|
||||
for (const btn of out.querySelectorAll("button[data-verify-registration]")) {
|
||||
btn.addEventListener("click", runHostStatus);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
// ---------- 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;
|
||||
try { chrome.storage.local.set({ badge_dupe_count: Number(r.group_count) || 0 }); } catch {}
|
||||
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() {
|
||||
// By the time any render call below runs, all script tags have parsed so
|
||||
// escapeHtml (from options.js, loaded last) is available. Render order:
|
||||
// success path uses saved values + defaults fallback per field; failure path
|
||||
// (RPC error / non-ok / thrown) renders pure defaults so UI stays populated
|
||||
// even if the native host RPC fails (e.g. stale allowed_origins after reinstall).
|
||||
const renderDefaults = () => {
|
||||
_krRenderVipList(KR_DEFAULT_VIP_FOLDERS);
|
||||
_krRenderFmtList(KR_DEFAULT_FMTS);
|
||||
};
|
||||
try {
|
||||
const r = await chrome.runtime.sendMessage({ type: "get-keep-ranking" });
|
||||
if (!r || !r.ok) { renderDefaults(); 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;
|
||||
const fmts = Array.isArray(ranking.format_preference) && ranking.format_preference.length
|
||||
? ranking.format_preference : KR_DEFAULT_FMTS;
|
||||
const vips = Array.isArray(ranking.priority_folders) && ranking.priority_folders.length
|
||||
? ranking.priority_folders : KR_DEFAULT_VIP_FOLDERS;
|
||||
_krRenderVipList(vips);
|
||||
_krRenderFmtList(fmts);
|
||||
} catch (e) {
|
||||
renderDefaults();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -0,0 +1,713 @@
|
||||
// ---- 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);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,265 @@
|
||||
// ---------- profiles ----------
|
||||
|
||||
let _knownRemotes = []; // ["cq:", "gdrive:", ...] from rclone listremotes
|
||||
let _cfgDefaults = { source: [], target: [] };
|
||||
let _remotesLoaded = false;
|
||||
|
||||
async function fetchRemotes() {
|
||||
const status = document.getElementById("profiles-status");
|
||||
if (_remotesLoaded) return;
|
||||
_remotesLoaded = true;
|
||||
if (status) status.textContent = "loading remotes...";
|
||||
try {
|
||||
const r = await chrome.runtime.sendMessage({ type: "list-remotes" });
|
||||
if (r && r.ok) {
|
||||
_knownRemotes = r.remotes || [];
|
||||
_cfgDefaults = { source: r.default_source || [], target: r.default_target || [] };
|
||||
if (status) status.textContent = `${_knownRemotes.length} remote(s) loaded`;
|
||||
// Re-render to populate selects now that we have data
|
||||
const profiles = readProfiles();
|
||||
renderProfiles(profiles);
|
||||
updateSectionSummaries();
|
||||
}
|
||||
} catch (e) {
|
||||
_remotesLoaded = false;
|
||||
if (status) status.textContent = "failed to load remotes";
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelector('.side .item[data-pane="profiles"]').addEventListener("click", fetchRemotes);
|
||||
document.getElementById("load-remotes").addEventListener("click", () => {
|
||||
_remotesLoaded = false;
|
||||
fetchRemotes();
|
||||
});
|
||||
|
||||
/**
|
||||
* Build a remote picker widget.
|
||||
* Shows: a <select> dropdown of known remotes + an editable path suffix input +
|
||||
* an Add button. Added remotes appear as chips below with × to remove.
|
||||
* Falls back gracefully to a plain text input if no remotes loaded yet.
|
||||
*/
|
||||
function buildRemotePicker(container, values) {
|
||||
container.innerHTML = "";
|
||||
|
||||
// --- selected list ---
|
||||
const selectedList = document.createElement("div");
|
||||
selectedList.className = "prof-selected-list";
|
||||
selectedList.style.cssText = "margin-bottom:6px;";
|
||||
container.appendChild(selectedList);
|
||||
|
||||
function addChip(path) {
|
||||
const chip = document.createElement("div");
|
||||
chip.className = "prof-chip";
|
||||
chip.style.cssText = "display:flex;align-items:center;gap:6px;margin-bottom:4px;";
|
||||
// Editable path so user can adjust subpath after picking
|
||||
const inp = document.createElement("input");
|
||||
inp.type = "text";
|
||||
inp.value = path;
|
||||
inp.className = "prof-chip-input";
|
||||
inp.style.cssText = "flex:1;font-family:Consolas,monospace;font-size:12px;";
|
||||
const rm = document.createElement("button");
|
||||
rm.type = "button";
|
||||
rm.textContent = "×";
|
||||
rm.title = "Remove";
|
||||
rm.style.cssText = "background:#511;border:1px solid #722;color:#faa;border-radius:3px;padding:0 7px;cursor:pointer;font-size:14px;line-height:1;";
|
||||
rm.addEventListener("click", () => chip.remove());
|
||||
chip.appendChild(inp);
|
||||
chip.appendChild(rm);
|
||||
selectedList.appendChild(chip);
|
||||
}
|
||||
|
||||
// Pre-populate with existing values
|
||||
for (const v of (values || [])) addChip(v);
|
||||
|
||||
// --- add row: select + optional subpath + Add button ---
|
||||
const addRow = document.createElement("div");
|
||||
addRow.style.cssText = "display:flex;gap:6px;align-items:center;flex-wrap:wrap;";
|
||||
|
||||
const sel = document.createElement("select");
|
||||
sel.style.cssText = "background:#0d0d0d;color:#ddd;border:1px solid #2a2a2a;border-radius:4px;padding:5px 8px;font-family:Consolas,monospace;font-size:12px;min-width:130px;";
|
||||
if (_knownRemotes.length) {
|
||||
for (const r of _knownRemotes) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = r;
|
||||
opt.textContent = r;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
} else {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "";
|
||||
opt.textContent = "(loading…)";
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
|
||||
const subpathInp = document.createElement("input");
|
||||
subpathInp.type = "text";
|
||||
subpathInp.placeholder = "optional/subpath";
|
||||
subpathInp.style.cssText = "flex:1;min-width:120px;font-family:Consolas,monospace;font-size:12px;";
|
||||
subpathInp.title = "Append a subpath to narrow the remote, e.g. JAV/ClearJAV";
|
||||
|
||||
const addBtn = document.createElement("button");
|
||||
addBtn.type = "button";
|
||||
addBtn.textContent = "+ Add";
|
||||
addBtn.style.cssText = "padding:5px 12px;font-size:12px;white-space:nowrap;";
|
||||
addBtn.addEventListener("click", () => {
|
||||
const base = sel.value.trim();
|
||||
if (!base) return;
|
||||
const sub = subpathInp.value.trim().replace(/^\//, "");
|
||||
const full = sub ? base + sub : base;
|
||||
addChip(full);
|
||||
subpathInp.value = "";
|
||||
});
|
||||
|
||||
// Also allow typing a fully custom path
|
||||
const customInp = document.createElement("input");
|
||||
customInp.type = "text";
|
||||
customInp.placeholder = "or type full path (e.g. cq:JAV/ClearJAV)";
|
||||
customInp.style.cssText = "flex:1;min-width:160px;font-family:Consolas,monospace;font-size:12px;margin-top:4px;";
|
||||
const customBtn = document.createElement("button");
|
||||
customBtn.type = "button";
|
||||
customBtn.textContent = "+ Add";
|
||||
customBtn.style.cssText = "padding:5px 12px;font-size:12px;margin-top:4px;white-space:nowrap;";
|
||||
customBtn.addEventListener("click", () => {
|
||||
const v = customInp.value.trim();
|
||||
if (v) { addChip(v); customInp.value = ""; }
|
||||
});
|
||||
customInp.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); customBtn.click(); } });
|
||||
|
||||
addRow.appendChild(sel);
|
||||
addRow.appendChild(subpathInp);
|
||||
addRow.appendChild(addBtn);
|
||||
container.appendChild(addRow);
|
||||
|
||||
const customRow = document.createElement("div");
|
||||
customRow.style.cssText = "display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:4px;";
|
||||
customRow.appendChild(customInp);
|
||||
customRow.appendChild(customBtn);
|
||||
container.appendChild(customRow);
|
||||
}
|
||||
|
||||
function readRemoteGroup(container) {
|
||||
return [...container.querySelectorAll(".prof-chip-input")]
|
||||
.map(i => i.value.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function describeProfileRoots(label, values, defaults) {
|
||||
const roots = (values || []).filter(Boolean);
|
||||
if (roots.length) return `${label}: ${roots.join(", ")}`;
|
||||
if ((defaults || []).length) return `${label}: config default ${defaults.join(", ")}`;
|
||||
return `${label}: config.json default`;
|
||||
}
|
||||
|
||||
function cloneProfile(profile) {
|
||||
return {
|
||||
name: (profile?.name || "").trim(),
|
||||
source: [...(profile?.source || [])].filter(Boolean),
|
||||
target: [...(profile?.target || [])].filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
function buildProfileRow(profile) {
|
||||
const p = cloneProfile(profile);
|
||||
const row = document.createElement("div");
|
||||
row.className = "profile-card prof-row";
|
||||
row._profile = p;
|
||||
const detail = document.createElement("div");
|
||||
detail.innerHTML = `
|
||||
<div class="name">${escapeHtml(p.name)}</div>
|
||||
<div class="roots">${escapeHtml(describeProfileRoots("Source", p.source, _cfgDefaults.source))}<br>${escapeHtml(describeProfileRoots("Target", p.target, _cfgDefaults.target))}</div>
|
||||
`;
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "actions";
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.type = "button";
|
||||
editBtn.textContent = "Edit";
|
||||
editBtn.addEventListener("click", () => openProfileModal(row));
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.type = "button";
|
||||
delBtn.className = "danger";
|
||||
delBtn.textContent = "Remove";
|
||||
delBtn.addEventListener("click", () => {
|
||||
row.remove();
|
||||
updateSectionSummaries();
|
||||
});
|
||||
actions.appendChild(editBtn);
|
||||
actions.appendChild(delBtn);
|
||||
row.appendChild(detail);
|
||||
row.appendChild(actions);
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderProfiles(profiles) {
|
||||
const list = document.getElementById("profiles-list");
|
||||
list.innerHTML = "";
|
||||
if (!profiles.length) {
|
||||
const msg = document.createElement("div");
|
||||
msg.style.cssText = "color:#666;font-size:12px;font-style:italic;margin-bottom:8px;";
|
||||
msg.textContent = "No profiles defined. Searches use rc-jav's config.json defaults.";
|
||||
list.appendChild(msg);
|
||||
return;
|
||||
}
|
||||
for (const p of profiles) list.appendChild(buildProfileRow(p));
|
||||
}
|
||||
|
||||
function readProfiles() {
|
||||
return [...document.querySelectorAll("#profiles-list .prof-row")]
|
||||
.map((row) => cloneProfile(row._profile))
|
||||
.filter((profile) => profile.name);
|
||||
}
|
||||
|
||||
let editingProfileRow = null;
|
||||
|
||||
function setProfileModalDefaultsNote() {
|
||||
const src = _cfgDefaults.source.length ? _cfgDefaults.source.join(", ") : "config.json default_source";
|
||||
const tgt = _cfgDefaults.target.length ? _cfgDefaults.target.join(", ") : "config.json default_target";
|
||||
document.getElementById("profile-modal-status").textContent = `Empty remote lists inherit source ${src} and target ${tgt}.`;
|
||||
}
|
||||
|
||||
async function openProfileModal(row = null) {
|
||||
editingProfileRow = row;
|
||||
await fetchRemotes();
|
||||
const profile = cloneProfile(row?._profile);
|
||||
document.getElementById("profile-modal-title").textContent = row ? "Edit Library Profile" : "Add Library Profile";
|
||||
document.getElementById("profile-modal-name").value = profile.name;
|
||||
buildRemotePicker(document.getElementById("profile-modal-source"), profile.source);
|
||||
buildRemotePicker(document.getElementById("profile-modal-target"), profile.target);
|
||||
setProfileModalDefaultsNote();
|
||||
openModal("profile-modal");
|
||||
document.getElementById("profile-modal-name").focus();
|
||||
}
|
||||
|
||||
function closeProfileModal() {
|
||||
editingProfileRow = null;
|
||||
closeModal("profile-modal");
|
||||
}
|
||||
|
||||
document.getElementById("add-profile").addEventListener("click", () => openProfileModal());
|
||||
document.getElementById("profile-modal-save").addEventListener("click", () => {
|
||||
const name = document.getElementById("profile-modal-name").value.trim();
|
||||
const status = document.getElementById("profile-modal-status");
|
||||
if (!name) {
|
||||
status.textContent = "Profile name is required.";
|
||||
return;
|
||||
}
|
||||
const profile = {
|
||||
name,
|
||||
source: readRemoteGroup(document.getElementById("profile-modal-source")),
|
||||
target: readRemoteGroup(document.getElementById("profile-modal-target")),
|
||||
};
|
||||
const list = document.getElementById("profiles-list");
|
||||
if (editingProfileRow) {
|
||||
editingProfileRow.replaceWith(buildProfileRow(profile));
|
||||
} else {
|
||||
list.querySelector("div[style*='italic']")?.remove();
|
||||
list.appendChild(buildProfileRow(profile));
|
||||
}
|
||||
closeProfileModal();
|
||||
updateSectionSummaries();
|
||||
});
|
||||
for (const id of ["profile-modal-close", "profile-modal-cancel"]) {
|
||||
document.getElementById(id).addEventListener("click", closeProfileModal);
|
||||
}
|
||||
document.getElementById("profile-modal").addEventListener("click", (event) => {
|
||||
if (event.target.id === "profile-modal") closeProfileModal();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
// ---------- adapters ----------
|
||||
|
||||
function renderAdapters(list) {
|
||||
const tbody = document.querySelector("#adapters tbody");
|
||||
tbody.innerHTML = "";
|
||||
for (const a of list) addAdapterRow(a.host || "", a.selector || "");
|
||||
if (list.length === 0) addAdapterRow("", "");
|
||||
}
|
||||
|
||||
function addAdapterRow(host, selector) {
|
||||
const tbody = document.querySelector("#adapters tbody");
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td><input type="text" class="host" placeholder="clearjav.com"></td>
|
||||
<td><input type="text" class="selector" placeholder=".some-class"></td>
|
||||
<td><button class="del" type="button">×</button></td>`;
|
||||
tr.querySelector(".host").value = host;
|
||||
tr.querySelector(".selector").value = selector;
|
||||
tr.querySelector(".del").addEventListener("click", () => tr.remove());
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
function readAdapters() {
|
||||
const rows = document.querySelectorAll("#adapters tbody tr");
|
||||
const out = [];
|
||||
for (const tr of rows) {
|
||||
const host = tr.querySelector(".host").value.trim();
|
||||
const selector = tr.querySelector(".selector").value.trim();
|
||||
if (host && selector) out.push({ host, selector });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
document.getElementById("add-adapter").addEventListener("click", () => addAdapterRow("", ""));
|
||||
|
||||
document.getElementById("validate-adapters").addEventListener("click", () => {
|
||||
const status = document.getElementById("picker-status");
|
||||
const rows = [...document.querySelectorAll("#adapters tbody tr")];
|
||||
const seen = new Set();
|
||||
const issues = [];
|
||||
for (const tr of rows) {
|
||||
const host = tr.querySelector(".host").value.trim();
|
||||
const selector = tr.querySelector(".selector").value.trim();
|
||||
tr.style.outline = "";
|
||||
if (!host && !selector) continue;
|
||||
if (!host || !selector) {
|
||||
issues.push("rows need both host and selector");
|
||||
tr.style.outline = "1px solid #775";
|
||||
continue;
|
||||
}
|
||||
const key = host.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
issues.push(`duplicate host: ${host}`);
|
||||
tr.style.outline = "1px solid #775";
|
||||
}
|
||||
seen.add(key);
|
||||
try { document.querySelector(selector); } catch {
|
||||
issues.push(`invalid CSS selector for ${host}`);
|
||||
tr.style.outline = "1px solid #775";
|
||||
}
|
||||
}
|
||||
status.textContent = issues.length ? [...new Set(issues)].join("; ") : `${readAdapters().length} adapter row(s) look valid`;
|
||||
updateSectionSummaries();
|
||||
});
|
||||
|
||||
// ---------- ID normalizers ----------
|
||||
|
||||
function renderNormalizers(list) {
|
||||
const tbody = document.querySelector("#normalizers tbody");
|
||||
tbody.innerHTML = "";
|
||||
for (const n of list) addNormalizerRow(n.re || "", n.fmt || "");
|
||||
if (list.length === 0) addNormalizerRow("", "");
|
||||
}
|
||||
|
||||
function addNormalizerRow(re, fmt) {
|
||||
const tbody = document.querySelector("#normalizers tbody");
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td><input type="text" class="re" placeholder="\\b1pondo-?(\\d{4,})-?(\\d{2,})\\b"></td>
|
||||
<td><input type="text" class="fmt" placeholder="1pondo-$1-$2"></td>
|
||||
<td><button class="del" type="button">×</button></td>`;
|
||||
tr.querySelector(".re").value = re;
|
||||
tr.querySelector(".fmt").value = fmt;
|
||||
tr.querySelector(".del").addEventListener("click", () => tr.remove());
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
function readNormalizers() {
|
||||
const rows = document.querySelectorAll("#normalizers tbody tr");
|
||||
const out = [];
|
||||
for (const tr of rows) {
|
||||
const re = tr.querySelector(".re").value.trim();
|
||||
const fmt = tr.querySelector(".fmt").value.trim();
|
||||
if (re && fmt) out.push({ re, fmt });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
document.getElementById("add-normalizer").addEventListener("click", () => addNormalizerRow("", ""));
|
||||
|
||||
document.getElementById("validate-normalizers").addEventListener("click", () => {
|
||||
const status = document.getElementById("normalizer-status");
|
||||
const rows = [...document.querySelectorAll("#normalizers tbody tr")];
|
||||
const issues = [];
|
||||
for (const tr of rows) {
|
||||
tr.style.outline = "";
|
||||
const re = tr.querySelector(".re").value.trim();
|
||||
const fmt = tr.querySelector(".fmt").value.trim();
|
||||
if (!re && !fmt) continue;
|
||||
if (!re || !fmt) {
|
||||
issues.push("rows need both regex and replacement");
|
||||
tr.style.outline = "1px solid #775";
|
||||
continue;
|
||||
}
|
||||
try { new RegExp(re, "i"); } catch (err) {
|
||||
issues.push(`invalid regex: ${err.message}`);
|
||||
tr.style.outline = "1px solid #775";
|
||||
}
|
||||
}
|
||||
status.textContent = issues.length ? issues.join("; ") : `${readNormalizers().length} normalizer row(s) look valid`;
|
||||
updateSectionSummaries();
|
||||
});
|
||||
|
||||
// ---------- custom part detectors ----------
|
||||
|
||||
const PART_DETECTOR_SAMPLES = [
|
||||
"KV-118 - Aiba Reika_PART1.mp4",
|
||||
"KV-118 - Aiba Reika_PART2.mp4",
|
||||
"KV-118 - Aiba Reika_PART3.mp4",
|
||||
"KV-118_1.mp4",
|
||||
"KV-118_2.mp4",
|
||||
"KV-118-pt1.mp4",
|
||||
"KV-118-part2.mp4",
|
||||
"KV-118-cd1.mp4",
|
||||
"KV-118-disc2.mp4",
|
||||
"KV-118 (1).mp4",
|
||||
"KV-118 (1 of 3).mp4",
|
||||
"KV-118.1of3.mp4",
|
||||
"KV-118-2 of 4.mp4",
|
||||
"OFJE-195-1 [480p].mp4",
|
||||
"OFJE-195-2 [480p].mp4",
|
||||
"OFJE-195-3 [480p].mp4",
|
||||
"KV-118_A.mp4",
|
||||
"KV-118-B.mp4",
|
||||
"KV-118A.mp4",
|
||||
"KV-118 1.mp4",
|
||||
"KV-118-P1.mp4",
|
||||
"KV-118_P2.mp4",
|
||||
"KV-118 Part 3.mp4",
|
||||
"KV-118_EP1.mp4",
|
||||
"KV-118 Episode 2.mp4",
|
||||
"KV-118_Vol1.mp4",
|
||||
"KV-118 Volume 2.mp4",
|
||||
"KV-118_Scene1.mp4",
|
||||
"KV-118_Side-A.mp4",
|
||||
];
|
||||
|
||||
const BUILTIN_PART_DETECTORS = [
|
||||
{ pattern: "[-_ ](?:pt|part|cd|disc)[-_ ]?(\\d+)$", note: "pt / part / cd / disc number" },
|
||||
{ pattern: "\\s*\\((\\d+)(?:\\s*of\\s*\\d+)?\\)$", note: "parenthesized part number or X of Y" },
|
||||
{ pattern: "[._ -](\\d+)\\s*of\\s*\\d+$", note: "X of Y suffix" },
|
||||
{ pattern: "_(\\d{1,2})$", note: "underscore number" },
|
||||
{ pattern: "-(\\d{1,2})$", note: "hyphen short part number" },
|
||||
{ pattern: "[-_]([A-D])$", note: "lettered part with separator" },
|
||||
{ pattern: "(?<=\\d)([A-D])$", note: "lettered part directly after ID" },
|
||||
{ pattern: "\\s+(\\d{1,2})$", note: "trailing spaced number" },
|
||||
];
|
||||
|
||||
function partDetectorStem(filename) {
|
||||
return filename.replace(/\.[^.]+$/, "");
|
||||
}
|
||||
|
||||
function partDetectorStemStages(filename) {
|
||||
const raw = partDetectorStem(filename);
|
||||
const resolutionClean = raw.replace(/\s*\[[^\]]*\]\s*$/, "").trim();
|
||||
let actressClean = resolutionClean;
|
||||
if (actressClean.includes(" - ")) actressClean = actressClean.slice(0, actressClean.indexOf(" - ")).trim();
|
||||
const stages = [];
|
||||
for (const [label, stem] of [
|
||||
["raw stem", raw],
|
||||
["after trailing metadata cleanup", resolutionClean],
|
||||
["after actress cleanup", actressClean],
|
||||
]) {
|
||||
if (stem && !stages.some((stage) => stage.stem === stem)) stages.push({ label, stem });
|
||||
}
|
||||
return stages;
|
||||
}
|
||||
|
||||
function partDetectorRegex(pattern) {
|
||||
// Custom detectors are Python regexes, but the common detector subset is
|
||||
// shared with browser RegExp. Preview the representative shapes here; rc-jav
|
||||
// remains authoritative when the saved rule runs during scan/search.
|
||||
return new RegExp(pattern, "i");
|
||||
}
|
||||
|
||||
function builtinPartCoverage(filename) {
|
||||
for (const detector of BUILTIN_PART_DETECTORS) {
|
||||
try {
|
||||
const re = partDetectorRegex(detector.pattern);
|
||||
for (const stage of partDetectorStemStages(filename)) {
|
||||
const match = stage.stem.match(re);
|
||||
if (match && match[1]) return detector;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function updatePartDetectorFeedback(row) {
|
||||
const feedback = row.querySelector(".part-detector-feedback");
|
||||
const pattern = row.querySelector(".part-detector-pattern").value.trim();
|
||||
if (!pattern) {
|
||||
feedback.innerHTML = `<span class="warn">Enter a detector regex.</span> Capture group 1 should be the part token.`;
|
||||
return;
|
||||
}
|
||||
let re;
|
||||
try {
|
||||
re = partDetectorRegex(pattern);
|
||||
} catch (err) {
|
||||
feedback.innerHTML = `<span class="fail">Invalid preview regex:</span> ${escapeHtml(err.message || String(err))}`;
|
||||
return;
|
||||
}
|
||||
const matches = [];
|
||||
let missingCapture = false;
|
||||
for (const filename of PART_DETECTOR_SAMPLES) {
|
||||
for (const stage of partDetectorStemStages(filename)) {
|
||||
const match = stage.stem.match(re);
|
||||
if (!match) continue;
|
||||
if (!match[1]) missingCapture = true;
|
||||
matches.push({ filename, part: match[1] || "?", stage: stage.label });
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matches.length) {
|
||||
feedback.innerHTML = `<span class="warn">No representative sample matched.</span> The rule may still be valid for a library-specific filename shape.`;
|
||||
return;
|
||||
}
|
||||
const isBuiltin = row.classList.contains("builtin");
|
||||
const covered = !isBuiltin ? matches.map((item) => ({ item, detector: builtinPartCoverage(item.filename) })) : [];
|
||||
const alreadyCovered = covered.length && covered.every((entry) => entry.detector);
|
||||
const coveredNote = alreadyCovered
|
||||
? `<div class="warn">These representative matches are already covered by built-in detector${new Set(covered.map((entry) => entry.detector.pattern)).size === 1 ? "" : "s"}.</div>`
|
||||
: "";
|
||||
feedback.innerHTML = [
|
||||
`<span class="${missingCapture ? "warn" : "ok"}">${missingCapture ? "Matched, but capture group 1 was missing for a sample." : `Matches ${matches.length} representative filename shape${matches.length === 1 ? "" : "s"}.`}</span>`,
|
||||
coveredNote,
|
||||
...matches.slice(0, 4).map((item) => `<div class="part-detector-match">${escapeHtml(item.filename)} -> part ${escapeHtml(item.part)} <span style="color:#777;">(${escapeHtml(item.stage)})</span></div>`),
|
||||
matches.length > 4 ? `<div>and ${escapeHtml(matches.length - 4)} more representative match(es)</div>` : "",
|
||||
].filter(Boolean).join("");
|
||||
}
|
||||
|
||||
function addPartDetectorRow(pattern = "", { builtin = false, note = "" } = {}) {
|
||||
const list = document.getElementById(builtin ? "builtin-part-detectors" : "part-detectors");
|
||||
const row = document.createElement("div");
|
||||
row.className = "part-detector-row" + (builtin ? " builtin" : "");
|
||||
row.innerHTML = `
|
||||
<div class="part-detector-head">
|
||||
<input type="text" class="part-detector-pattern" placeholder="_PART(\\d+)$"${builtin ? " readonly" : ""}>
|
||||
${builtin ? `<span class="part-detector-kind">Built in</span>` : `<button type="button" title="Remove detector">x</button>`}
|
||||
</div>
|
||||
${note ? `<div class="muted" style="margin-top:5px;">${escapeHtml(note)}</div>` : ""}
|
||||
<div class="part-detector-feedback"></div>
|
||||
`;
|
||||
row.querySelector(".part-detector-pattern").value = pattern;
|
||||
if (!builtin) {
|
||||
row.querySelector(".part-detector-pattern").addEventListener("input", () => {
|
||||
updatePartDetectorFeedback(row);
|
||||
updateSectionSummaries();
|
||||
});
|
||||
row.querySelector("button").addEventListener("click", () => {
|
||||
row.remove();
|
||||
if (!list.children.length) addPartDetectorRow("");
|
||||
updateSectionSummaries();
|
||||
});
|
||||
}
|
||||
list.appendChild(row);
|
||||
updatePartDetectorFeedback(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
function renderPartDetectors(patterns) {
|
||||
const builtinList = document.getElementById("builtin-part-detectors");
|
||||
const list = document.getElementById("part-detectors");
|
||||
builtinList.innerHTML = "";
|
||||
list.innerHTML = "";
|
||||
for (const detector of BUILTIN_PART_DETECTORS) addPartDetectorRow(detector.pattern, { builtin: true, note: detector.note });
|
||||
for (const pattern of patterns || []) addPartDetectorRow(pattern);
|
||||
if (!list.children.length) addPartDetectorRow("");
|
||||
}
|
||||
|
||||
function readPartDetectors() {
|
||||
return [...document.querySelectorAll("#part-detectors .part-detector-pattern")]
|
||||
.map((input) => input.value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
document.getElementById("add-part-detector").addEventListener("click", () => {
|
||||
addPartDetectorRow("").querySelector(".part-detector-pattern").focus();
|
||||
});
|
||||
|
||||
// Tester
|
||||
document.getElementById("norm-test-run").addEventListener("click", async () => {
|
||||
const input = document.getElementById("norm-test-in").value;
|
||||
const out = document.getElementById("norm-test-out");
|
||||
out.textContent = "testing text...";
|
||||
try {
|
||||
const r = await chrome.runtime.sendMessage({
|
||||
type: "test-id-text",
|
||||
text: input,
|
||||
normalizers: readNormalizers(),
|
||||
});
|
||||
if (!r || !r.ok) {
|
||||
out.innerHTML = `<span style="color:#faa;">error:</span> ${escapeHtml(r?.error || "no response")}`;
|
||||
return;
|
||||
}
|
||||
const e = r.extracted || {};
|
||||
out.innerHTML = [
|
||||
`<div><span style="color:#777;">ID:</span> <span style="color:${e.id ? "#afa" : "#faa"};">${escapeHtml(e.id || "none")}</span></div>`,
|
||||
`<div><span style="color:#777;">Rule:</span> ${escapeHtml(e.source || "none")}</div>`,
|
||||
e.pattern ? `<div><span style="color:#777;">Pattern:</span> ${escapeHtml(e.pattern)}</div>` : "",
|
||||
e.replacement ? `<div><span style="color:#777;">Replacement:</span> ${escapeHtml(e.replacement)}</div>` : "",
|
||||
e.raw ? `<div><span style="color:#777;">Raw:</span> ${escapeHtml(e.raw)}</div>` : "",
|
||||
].filter(Boolean).join("");
|
||||
} catch (err) {
|
||||
out.innerHTML = `<span style="color:#faa;">error:</span> ${escapeHtml(err.message || String(err))}`;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
}[c]));
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
/* rclone-jav options page styles
|
||||
* Extracted from options.html step 2 of the console consolidation refactor.
|
||||
* See mockups/console-consolidation-claude.html for sequence + rationale.
|
||||
* Per-pane split happens later (step 6) alongside per-pane JS extraction. */
|
||||
|
||||
html { scrollbar-gutter: stable; }
|
||||
body { font-family: -apple-system, Segoe UI, sans-serif; background: #0f0f0f; color: #ddd; margin: 0; padding: 24px; }
|
||||
.shell {
|
||||
max-width: 1040px; margin: 0 auto;
|
||||
background: #1a1a1a; border: 1px solid #2a2a2a; border-radius: 8px; overflow: hidden;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,.4);
|
||||
}
|
||||
.layout { display: grid; grid-template-columns: 220px 1fr; min-height: 620px; }
|
||||
|
||||
/* sidebar */
|
||||
.side { background: #131313; border-right: 1px solid #222; padding: 14px 0; position: sticky; top: 0; align-self: start; height: 100vh; max-height: 720px; overflow-y: auto; }
|
||||
.side .brand { padding: 2px 22px 16px; border-bottom: 1px solid #202020; margin-bottom: 14px; }
|
||||
.side .brand strong { display:block; color:#f3f3f3; font-size:14px; letter-spacing:.2px; }
|
||||
.side .brand span { display:block; color:#666; font-size:11px; margin-top:3px; }
|
||||
.side .group { padding: 0 14px; margin-bottom: 16px; }
|
||||
.side .group:last-child { margin-bottom: 0; }
|
||||
.side .gtitle { font-size: 10px; color: #555; text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 6px; padding: 0 8px; }
|
||||
.side .item { padding: 8px 10px; font-size: 13px; color: #aaa; cursor: pointer; border-radius: 5px; user-select: none; display: flex; align-items: center; gap: 9px; }
|
||||
.side .item .icon { width: 16px; opacity: 0.72; text-align:center; font-size:12px; }
|
||||
.side .item:hover { background: #1f1f1f; color: #ddd; }
|
||||
.side .item.active { background: #2a2a2a; color: #fff; box-shadow: inset 2px 0 #6ec1ff; }
|
||||
.side .item.danger { color: #faa; }
|
||||
.side .item.danger:hover { background: #2a1a1a; }
|
||||
.side .item.danger.active { background: #3a1a1a; color: #ffbbbb; }
|
||||
.side .item .label { flex: 1; }
|
||||
.side .side-badge { font-size: 10px; font-weight: 600; color: #a7b2bb; background: #2d343a; border: 1px solid transparent; border-radius: 10px; padding: 1px 7px; min-width: 18px; text-align: center; }
|
||||
.side .side-badge:empty { display: none; }
|
||||
.side .side-badge.warn { background: #3a3017; color: #ffd784; border-color: #645228; }
|
||||
.side .side-badge.fresh { background: #1d3826; color: #9be3b3; border-color: #245036; }
|
||||
.side .side-note { font-size: 11px; color: #666; font-style: italic; padding: 6px 10px 0; line-height: 1.4; }
|
||||
|
||||
/* main pane */
|
||||
.main { padding: 26px 32px; overflow-y: auto; }
|
||||
.pane-head { margin-bottom: 18px; padding-bottom: 14px; border-bottom: 1px solid #242424; }
|
||||
.main h1 { margin: 0 0 5px; font-size: 19px; letter-spacing:.2px; }
|
||||
.main .pdesc { color: #888; font-size: 12px; margin: 0; line-height: 1.5; max-width: 700px; }
|
||||
.pane { display: none; }
|
||||
.pane.active { display: block; }
|
||||
|
||||
/* common form bits */
|
||||
label { display: flex; align-items: center; gap: 10px; padding: 7px 0; font-size: 13px; cursor: pointer; }
|
||||
label .sublabel { color: #777; font-size: 11px; display: block; margin-top: 2px; }
|
||||
input[type=text], textarea {
|
||||
width: 100%; background: #0d0d0d; color: #ddd; border: 1px solid #2a2a2a;
|
||||
padding: 8px 10px; box-sizing: border-box; border-radius: 4px;
|
||||
font-family: Consolas, monospace; font-size: 12px;
|
||||
}
|
||||
textarea { min-height: 80px; resize: vertical; }
|
||||
input[type=text]:focus, textarea:focus { border-color: #ff6f3c; outline: none; }
|
||||
.fieldset { background: #161616; border: 1px solid #2a2a2a; border-radius: 5px; padding: 13px 15px; margin-bottom: 12px; }
|
||||
.fieldset .ftitle { font-size: 11px; color: #999; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; font-weight: 650; }
|
||||
.help { color: #777; font-size: 11px; margin: 4px 0 10px; line-height: 1.4; }
|
||||
.help code { background: #0d0d0d; padding: 2px 4px; border-radius: 3px; font-size: 11px; }
|
||||
select, input[type=number], input[type=color] { background:#0d0d0d; color:#ddd; border:1px solid #2a2a2a; border-radius:4px; font-family:Consolas,monospace; font-size:12px; }
|
||||
select { padding: 6px 8px; }
|
||||
input[type=number] { padding:6px 8px; }
|
||||
.setting-list { display:grid; gap: 2px; }
|
||||
.setting-list label { border-bottom: 1px solid #222; padding: 9px 0; }
|
||||
.setting-list label:last-child { border-bottom: 0; }
|
||||
.split-grid { display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
||||
.button-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||
.section-note { background:#101820; border:1px solid #24303a; color:#9dccff; border-radius:5px; padding:9px 11px; font-size:12px; line-height:1.45; margin-bottom:12px; }
|
||||
.section-note.warn { background:#242410; border-color:#4a4420; color:#ffdd77; }
|
||||
.section-note.danger { background:#2a1414; border-color:#5a2525; color:#ffb3b3; }
|
||||
.mono-output { margin-top:10px; font-family:Consolas,monospace; font-size:11px; color:#aaa; line-height:1.5; overflow-wrap:anywhere; }
|
||||
.compact-grid { display:grid; grid-template-columns: minmax(0, 1fr) auto; gap:8px; align-items:center; }
|
||||
.chip-row { display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
|
||||
.chip-btn { padding: 4px 9px; font-size:11px; border-radius: 10px; }
|
||||
.activity-filters { display:flex; gap:6px; align-items:center; flex-wrap:wrap; margin-top:9px; }
|
||||
.activity-filter {
|
||||
padding:4px 10px; border-radius:12px; font-size:11px;
|
||||
display:inline-flex; align-items:center; gap:8px;
|
||||
/* equal width regardless of label / count length */
|
||||
min-width:120px; justify-content:space-between;
|
||||
background:#1a1a1a; border:1px solid #2a2a2a; color:#bbb;
|
||||
font-variant-numeric:tabular-nums;
|
||||
}
|
||||
.activity-filter .af-cnt {
|
||||
/* fits 3 digits without resizing the chip */
|
||||
min-width:26px; text-align:right;
|
||||
background:rgba(255,255,255,0.05); border-radius:9px; padding:0 6px;
|
||||
color:#888; font-size:10px; font-weight:600;
|
||||
}
|
||||
.activity-filter:hover { background:#222; color:#ddd; }
|
||||
.activity-filter.active { background:#1a2430; border-color:#36526a; color:#9dccff; }
|
||||
.activity-filter.active .af-cnt { color:#9dccff; background:rgba(157,204,255,0.12); }
|
||||
/* outcome tones — apply when active so the chip mirrors the row pill colors */
|
||||
.activity-filter.af-hit.active { background:#143020; border-color:#245036; color:#9be3b3; }
|
||||
.activity-filter.af-hit.active .af-cnt { color:#9be3b3; background:rgba(155,227,179,0.12); }
|
||||
.activity-filter.af-miss.active { background:#321618; border-color:#5b2228; color:#ff9097; }
|
||||
.activity-filter.af-miss.active .af-cnt { color:#ff9097; background:rgba(255,144,151,0.12); }
|
||||
.activity-filter.af-other.active { background:#332b16; border-color:#645228; color:#ffd784; }
|
||||
.activity-filter.af-other.active .af-cnt { color:#ffd784; background:rgba(255,215,132,0.12); }
|
||||
.activity-entry { margin-top:8px; }
|
||||
.activity-head { display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
|
||||
.activity-pill { border:1px solid #333; border-radius:11px; padding:2px 8px; font-size:10px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; white-space:nowrap; }
|
||||
.activity-pill.hit { background:#15351e; border-color:#285c38; color:#8ff0ae; }
|
||||
.activity-pill.miss { background:#383315; border-color:#655a24; color:#ffe487; }
|
||||
.activity-pill.no-id { background:#172839; border-color:#2d4f70; color:#9dccff; }
|
||||
.activity-pill.paused { background:#292334; border-color:#493b62; color:#d0b5ff; }
|
||||
.activity-pill.error { background:#3a1a1a; border-color:#722; color:#faa; }
|
||||
.activity-meta { color:#aaa; }
|
||||
/* Non-JAV files panel (skipped files in cache status) */
|
||||
.nonjav-panel { margin-top: 8px; background: rgba(255,255,255,.02); border: 1px solid #1e1e2a; border-radius: 6px; overflow: hidden; }
|
||||
.nonjav-panel-head { display: flex; align-items: center; gap: 8px; padding: 7px 12px; background: rgba(255,255,255,.03); border-bottom: 1px solid #1e1e2a; }
|
||||
.nonjav-panel-title { font-size: 12px; color: #b0b0c8; flex: 1; }
|
||||
.nonjav-del-all { font-size: 11px; padding: 3px 10px; border-radius: 10px; background: rgba(248,113,113,.12); border: 1px solid rgba(248,113,113,.25); color: #fca5a5; cursor: pointer; }
|
||||
.nonjav-del-all:hover { background: rgba(248,113,113,.22); }
|
||||
.nonjav-del-all:disabled { opacity: 0.4; cursor: default; }
|
||||
.nonjav-list { max-height: 220px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #2a2a3a #0d0d1a; }
|
||||
.nonjav-item { display: flex; align-items: center; gap: 8px; padding: 5px 12px; border-bottom: 1px solid rgba(255,255,255,.04); font-size: 11px; }
|
||||
.nonjav-item:last-child { border-bottom: none; }
|
||||
.nonjav-item.deleted { opacity: 0.35; pointer-events: none; }
|
||||
.nonjav-ext { font-family: Consolas, monospace; font-size: 10px; padding: 1px 5px; border-radius: 3px; background: rgba(255,255,255,.06); color: #888; flex-shrink: 0; min-width: 36px; text-align: center; }
|
||||
.nonjav-path { flex: 1; font-family: Consolas, monospace; color: #9090b0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.nonjav-del-one { font-size: 10px; padding: 2px 8px; border-radius: 8px; background: rgba(248,113,113,.08); border: 1px solid rgba(248,113,113,.2); color: #f87171; cursor: pointer; flex-shrink: 0; }
|
||||
.nonjav-del-one:hover { background: rgba(248,113,113,.18); }
|
||||
.nonjav-status { font-size: 11px; padding: 5px 12px; color: #8888aa; }
|
||||
.muted { color:#777; font-size:11px; }
|
||||
.disabled-soft { opacity:.48; }
|
||||
.danger-zone { border-color:#5a2525; background:#201414; }
|
||||
.scan-job-title { display:flex; gap:10px; align-items:center; flex-wrap:wrap; color:#8b8ba8; font-size:12px; margin-bottom:8px; }
|
||||
.scan-job-head { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:10px; }
|
||||
.scan-job-meta { color:#d8d8ec; font-size:12px; }
|
||||
.scan-pill { border-radius:10px; padding:2px 8px; background:#1a2430; color:#9dccff; border:1px solid #2d4258; font-size:10px; font-weight:700; text-transform:uppercase; }
|
||||
.scan-pill.ok { background:#1a3a1a; color:#afa; border-color:#2e5a2e; }
|
||||
.scan-pill.fail { background:#3a1a1a; color:#faa; border-color:#722; }
|
||||
.scan-metrics { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:6px; margin:0 0 10px; }
|
||||
.scan-metric { display:flex; flex-direction:column; gap:4px; min-height:42px; padding:6px 8px; border:1px solid #263042; border-radius:5px; background:#111722; color:#8d95ad; font-size:11px; }
|
||||
.scan-metric span { white-space:nowrap; }
|
||||
.scan-metric b { color:#e6eefc; font-size:12px; font-weight:700; white-space:nowrap; text-align:right; overflow:hidden; text-overflow:ellipsis; }
|
||||
.scan-remote { border-top:1px solid #242424; padding:9px 0; }
|
||||
.scan-remote:first-of-type { border-top:0; }
|
||||
.scan-remote-head { display:flex; align-items:center; gap:8px; min-width:0; }
|
||||
.scan-remote-name { color:#9dccff; font-size:12px; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.scan-remote-status { color:#8b8ba8; font-size:11px; }
|
||||
.scan-remote-pct { margin-left:auto; color:#d8eaff; font-size:12px; font-weight:700; }
|
||||
.scan-remote-detail { color:#8c8ca6; font-size:12px; margin-top:3px; }
|
||||
.scan-track { height:5px; margin-top:7px; background:#202432; border-radius:3px; overflow:hidden; }
|
||||
.scan-fill { height:100%; background:#6ec1ff; min-width:0; }
|
||||
|
||||
/* buttons */
|
||||
button { background: #2a2a2a; color: #ddd; border: 1px solid #3a3a3a; border-radius: 4px; padding: 6px 14px; font-size: 12px; cursor: pointer; font-family: inherit; }
|
||||
button:hover { background: #333; }
|
||||
button.primary { background: #1a3a1a; border-color: #2e5a2e; color: #afa; font-weight: 600; letter-spacing: 0.3px; }
|
||||
button.primary:hover { background: #235023; }
|
||||
button.danger { background: #511; border-color: #722; color: #faa; font-weight: 600; letter-spacing: 0.5px; }
|
||||
button.danger:hover { background: #722; }
|
||||
.actions { display: flex; gap: 8px; align-items: center; padding-top: 14px; border-top: 1px solid #222; margin-top: 20px; position: sticky; bottom: 0; background: linear-gradient(180deg, rgba(26,26,26,0), #1a1a1a 30%); }
|
||||
.actions .saved { color: #afa; font-size: 12px; margin-left: 8px; opacity: 0; transition: opacity .2s; }
|
||||
.actions .saved.show { opacity: 1; }
|
||||
|
||||
/* adapter table */
|
||||
.adapters { width: 100%; border-collapse: collapse; }
|
||||
.adapters th, .adapters td { padding: 6px 6px; text-align: left; font-size: 12px; border-bottom: 1px solid #232323; vertical-align: middle; }
|
||||
.adapters th { color: #888; font-weight: normal; }
|
||||
.adapters th:nth-child(1), .adapters td:nth-child(1) { width: 200px; }
|
||||
.adapters th:nth-child(3), .adapters td:nth-child(3) { width: 32px; text-align: right; }
|
||||
.adapters td input { width: 100%; height: 28px; padding: 4px 6px; }
|
||||
.adapters button.del { background: #511; border: 1px solid #722; color: #faa; padding: 0; width: 26px; height: 26px; line-height: 24px; font-size: 14px; border-radius: 3px; }
|
||||
|
||||
/* diagnostics rows */
|
||||
.diag-row { padding: 6px 10px; border-radius: 4px; margin-bottom: 4px; font-size: 12px; display: grid; grid-template-columns: 30px 150px 1fr; gap: 10px; align-items: start; }
|
||||
.diag-row.ok { background: #1a3a1a; color: #afa; }
|
||||
.diag-row.warn { background: #3a3a1a; color: #ffa; }
|
||||
.diag-row.fail { background: #3a1a1a; color: #faa; }
|
||||
.diag-row.info { background: #1a2430; color: #9dccff; }
|
||||
.diag-row .icon { font-weight: 700; }
|
||||
.diag-row .name { font-weight: 600; }
|
||||
.diag-row .detail { color: rgba(255,255,255,0.72); font-family: Consolas, monospace; font-size: 11px; overflow-wrap: anywhere; }
|
||||
.diag-row details { margin: 0; }
|
||||
.diag-row summary { cursor: pointer; color: rgba(255,255,255,0.85); }
|
||||
.diag-row pre { white-space: pre-wrap; margin: 6px 0 0; font-family: Consolas, monospace; font-size: 11px; color: rgba(255,255,255,0.72); }
|
||||
.diag-action { margin-top: 6px; display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.diag-action button { padding: 3px 8px; font-size: 11px; }
|
||||
|
||||
/* radio chips */
|
||||
.radio-group { display: flex; gap: 6px; margin: 10px 0; }
|
||||
.radio-group label { background: #161616; border: 1px solid #2a2a2a; padding: 6px 12px; border-radius: 4px; font-size: 12px; padding-top: 6px; padding-bottom: 6px; }
|
||||
.radio-group label input { margin: 0 6px 0 0; }
|
||||
.radio-group label.selected { background: #1a3a1a; border-color: #2e5a2e; color: #afa; }
|
||||
.radio-group label.selected.danger { background: #3a1a1a; border-color: #722; color: #faa; }
|
||||
|
||||
code { background: #0d0d0d; padding: 2px 5px; border-radius: 3px; font-size: 11px; font-family: Consolas, monospace; }
|
||||
|
||||
/* Overlay horizontal tabs */
|
||||
.overlay-tabs { display: flex; gap: 0; margin-bottom: 16px; border-bottom: 1px solid #2a2a2a; }
|
||||
.otab { background: none; border: none; border-bottom: 2px solid transparent; color: #666; font-size: 13px; font-weight: 600; padding: 8px 18px; cursor: pointer; border-radius: 0; margin-bottom: -1px; letter-spacing: 0.3px; transition: color .15s, border-color .15s; }
|
||||
.otab:hover { color: #aaa; background: none; }
|
||||
.otab.active { color: #ddd; border-bottom-color: #6ec1ff; }
|
||||
.otab-panel { display: none; }
|
||||
.otab-panel.active { display: block; }
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.72); z-index: 30; display: none; align-items: center; justify-content: center; padding: 24px; }
|
||||
.modal-backdrop.open { display: flex; }
|
||||
.modal { width: min(860px, 100%); max-height: min(760px, calc(100vh - 48px)); background:#161616; border:1px solid #343434; border-radius:8px; box-shadow:0 18px 80px rgba(0,0,0,.6); display:flex; flex-direction:column; overflow:hidden; }
|
||||
.modal-head, .modal-actions { display:flex; gap:10px; align-items:center; justify-content:space-between; padding:14px 16px; border-bottom:1px solid #262626; }
|
||||
.modal-actions { border-bottom:0; border-top:1px solid #262626; justify-content:flex-end; }
|
||||
.modal-title { font-size:15px; font-weight:650; color:#f1f1f1; }
|
||||
.modal-subtitle { margin-top:3px; color:#777; font-size:11px; font-family:Consolas,monospace; overflow-wrap:anywhere; }
|
||||
.modal-body { padding:14px 16px; overflow:auto; }
|
||||
.modal-help { color:#888; font-size:12px; line-height:1.45; margin-bottom:12px; }
|
||||
.modal-field { margin-bottom:12px; }
|
||||
.modal-field > label { display:block; color:#bbb; font-size:12px; padding:0; margin-bottom:5px; cursor:default; }
|
||||
.modal-field .prof-remote-group { background:#0a0a0a; border:1px solid #222; border-radius:4px; padding:8px 10px; }
|
||||
.profile-card { background:#161616; border:1px solid #2a2a2a; border-radius:5px; padding:12px 14px; margin-bottom:10px; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:12px; align-items:start; }
|
||||
.profile-card .name { color:#f1f1f1; font-weight:650; margin-bottom:5px; }
|
||||
.profile-card .roots { color:#888; font-family:Consolas,monospace; font-size:11px; line-height:1.5; overflow-wrap:anywhere; }
|
||||
.profile-card .actions { margin:0; display:flex; gap:6px; align-items:center; }
|
||||
.profile-card .actions button { padding:4px 9px; font-size:11px; }
|
||||
.modal-confirm { background:#201414; border:1px solid #5a2525; border-radius:5px; color:#ffb3b3; font-size:12px; line-height:1.5; padding:11px 12px; }
|
||||
.part-detector-list { display:grid; gap:9px; }
|
||||
.part-detector-row { background:#101010; border:1px solid #252525; border-radius:5px; padding:10px; }
|
||||
.part-detector-row.builtin { background:#101820; border-color:#24303a; }
|
||||
.part-detector-head { display:grid; grid-template-columns:minmax(0,1fr) 28px; gap:8px; align-items:center; }
|
||||
.part-detector-row.builtin .part-detector-head { grid-template-columns:minmax(0,1fr) auto; }
|
||||
.part-detector-head input { min-width:0; }
|
||||
.part-detector-head input:read-only { color:#9dccff; }
|
||||
.part-detector-head button { background:#511; border-color:#722; color:#faa; width:28px; height:28px; padding:0; font-size:14px; }
|
||||
.part-detector-kind { color:#9dccff; border:1px solid #314453; border-radius:10px; padding:2px 8px; font-size:10px; font-weight:650; white-space:nowrap; }
|
||||
.part-detector-feedback { margin-top:7px; font-size:11px; line-height:1.45; color:#888; }
|
||||
.part-detector-feedback .ok { color:#afa; }
|
||||
.part-detector-feedback .warn { color:#ffdd77; }
|
||||
.part-detector-feedback .fail { color:#faa; }
|
||||
.part-detector-match { color:#9dccff; font-family:Consolas,monospace; margin-top:3px; overflow-wrap:anywhere; }
|
||||
.skip-summary { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:12px; }
|
||||
.skip-summary span { background:#242410; border:1px solid #4a4420; color:#ffdd77; border-radius:10px; padding:3px 8px; font-size:11px; }
|
||||
.skip-row { border-top:1px solid #242424; padding:9px 0; }
|
||||
.skip-row:first-of-type { border-top:0; }
|
||||
.skip-row .name { color:#ddd; font-family:Consolas,monospace; font-size:12px; }
|
||||
.skip-row .reason { color:#ffdd77; font-size:11px; margin-top:3px; }
|
||||
.skip-row .path { color:#777; font-family:Consolas,monospace; font-size:11px; margin-top:3px; overflow-wrap:anywhere; }
|
||||
.cache-freshness { display:flex; gap:14px; align-items:center; flex-wrap:wrap; margin:8px 0 10px; }
|
||||
.cache-freshness label { display:inline-flex; gap:8px; align-items:center; padding:0; white-space:nowrap; cursor:default; }
|
||||
.cache-freshness input { width:66px; }
|
||||
.cache-freshness .sublabel { max-width:340px; margin:0; line-height:1.35; }
|
||||
|
||||
/* ── Duplicate Review — Variant 9 (Frosted Modern) ─────────────────── */
|
||||
#dupe-review-modal .modal { background: linear-gradient(135deg, #0f0f1a 0%, #0d1117 100%); border-color: #2a2a3a; width: 65vw; }
|
||||
#dupe-review-modal .modal-head { background: linear-gradient(90deg, #12122a, #0d0d1a); border-bottom-color: #1e1e3a; }
|
||||
#dupe-review-modal .modal-title { color: #e8e8ff; }
|
||||
#dupe-review-modal .modal-subtitle { color: #8888aa; font-family: system-ui, sans-serif; }
|
||||
#dupe-review-modal .modal-head button { color: #6060aa; border-color: #2a2a4a; }
|
||||
#dupe-review-modal .modal-head button:hover { color: #b0b0d0; background: rgba(255,255,255,.05); }
|
||||
#dupe-review-modal .modal-actions { border-top-color: #1a1a2a; background: rgba(0,0,0,.3); }
|
||||
#dupe-review-modal .modal-actions button { background: rgba(255,255,255,.07); border-color: #3a3a5a; color: #c8c8e8; }
|
||||
#dupe-review-modal .modal-actions button:hover { background: rgba(255,255,255,.13); }
|
||||
#dupe-review-modal .modal-body { padding: 0; scrollbar-width: thin; scrollbar-color: #2a2a4a #0d0d1a; }
|
||||
#dupe-review-modal .modal-body::-webkit-scrollbar { width: 6px; }
|
||||
#dupe-review-modal .modal-body::-webkit-scrollbar-track { background: #0d0d1a; }
|
||||
#dupe-review-modal .modal-body::-webkit-scrollbar-thumb { background: #2a2a4a; border-radius: 3px; }
|
||||
#dupe-review-modal .modal-body::-webkit-scrollbar-thumb:hover { background: #3a3a6a; }
|
||||
|
||||
.dr-stats { display: flex; border-bottom: 1px solid #1e1e32; }
|
||||
.dr-stat { flex: 1; padding: 11px 16px; border-right: 1px solid #1e1e32; }
|
||||
.dr-stat:last-child { border-right: none; }
|
||||
.dr-stat .val { font-size: 17px; font-weight: 700; color: #fff; }
|
||||
.dr-stat .val.red { color: #f87171; }
|
||||
.dr-stat .val.blue { color: #60a5fa; }
|
||||
.dr-stat .key { font-size: 10px; color: #aaaacc; margin-top: 2px; text-transform: uppercase; letter-spacing: .07em; }
|
||||
|
||||
.dr-roots { font-size: 12px; color: #9999bb; padding: 6px 16px; border-bottom: 1px solid #1a1a2a; font-family: Consolas, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.dr-body { padding: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
.dr-card-wrap { display: flex; align-items: stretch; }
|
||||
.dr-card-wrap.dr-hidden { display: none; }
|
||||
.dr-card-wrap.skipped .dr-card { opacity: 0.38; pointer-events: none; }
|
||||
.dr-card-wrap.skipped .dr-skip-ear { background: rgba(251,191,36,.07); border-color: rgba(251,191,36,.25); }
|
||||
.dr-card-wrap.skipped .dr-skip-ear span { color: #fbbf24; }
|
||||
.dr-card-wrap.dr-risk .dr-card { border-color: rgba(251,191,36,.28); }
|
||||
.dr-skip-ear { width: 24px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; cursor: pointer; background: rgba(255,255,255,.02); border: 1px solid rgba(255,255,255,.06); border-left: none; border-radius: 0 6px 6px 0; transition: background .15s; }
|
||||
.dr-skip-ear:hover { background: rgba(255,255,255,.07); }
|
||||
.dr-skip-ear span { font-size: 8px; color: #333; writing-mode: vertical-rl; letter-spacing: 1px; text-transform: uppercase; font-weight: 700; user-select: none; transition: color .15s; }
|
||||
.dr-skip-ear:hover span { color: #777; }
|
||||
.dr-card { background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.06); border-radius: 8px 0 0 8px; overflow: hidden; flex: 1; min-width: 0; }
|
||||
.dr-card-head { display: flex; align-items: center; padding: 9px 14px; gap: 8px; border-bottom: 1px solid rgba(255,255,255,.04); }
|
||||
.dr-card-id { font-weight: 700; color: #e0e0ff; font-family: Consolas, monospace; font-size: 13px; }
|
||||
.dr-card-reclaim { margin-left: auto; font-size: 11px; color: #ff7171; background: rgba(255,100,100,.1); padding: 2px 9px; border-radius: 12px; white-space: nowrap; }
|
||||
.dr-card-body { padding: 9px 14px; display: flex; flex-direction: column; gap: 7px; }
|
||||
|
||||
.dr-badge { font-size: 9px; font-weight: 700; letter-spacing: .07em; padding: 2px 7px; border-radius: 10px; white-space: nowrap; }
|
||||
.dr-badge.b4k { background: rgba(96,165,250,.12); color: #60a5fa; border: 1px solid rgba(96,165,250,.2); }
|
||||
.dr-badge.b1080 { background: rgba(167,139,250,.12); color: #a78bfa; border: 1px solid rgba(167,139,250,.2); }
|
||||
.dr-badge.bcljav { background: rgba(74,222,128,.12); color: #4ade80; border: 1px solid rgba(74,222,128,.2); }
|
||||
.dr-badge.bfmt { background: rgba(251,146,60,.12); color: #fb923c; border: 1px solid rgba(251,146,60,.2); }
|
||||
.dr-badge.bmkv { background: rgba(251,146,60,.09); color: #fb923c; border: 1px solid rgba(251,146,60,.15); }
|
||||
|
||||
.dr-row { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.dr-tag { font-size: 9px; font-weight: 700; letter-spacing: .08em; padding: 2px 8px; border-radius: 10px; min-width: 58px; text-align: center; white-space: nowrap; flex-shrink: 0; }
|
||||
.dr-tag.keep { background: rgba(74,222,128,.12); color: #4ade80; border: 1px solid rgba(74,222,128,.2); }
|
||||
.dr-tag.del { background: rgba(248,113,113,.12); color: #f87171; border: 1px solid rgba(248,113,113,.2); }
|
||||
.dr-tag.cat { background: rgba(96,165,250,.12); color: #93c5fd; border: 1px solid rgba(96,165,250,.2); }
|
||||
.dr-path { font-family: Consolas, monospace; font-size: 11px; color: #9090b0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; }
|
||||
.dr-row.keep .dr-path { color: #c0c0e0; }
|
||||
.dr-keep-reason { color:#8ff0ae; font-size:10px; font-family:Consolas,monospace; margin:-2px 0 2px 68px; }
|
||||
.dr-risk-note { background:rgba(251,191,36,.09); border:1px solid rgba(251,191,36,.24); border-radius:5px; color:#ffe487; font-size:11px; line-height:1.42; margin:0 0 3px; padding:7px 9px; }
|
||||
.dr-risk-note strong { color:#fff0b0; }
|
||||
.dr-sz { font-size: 11px; font-weight: 600; white-space: nowrap; flex-shrink: 0; }
|
||||
.dr-sz.keep { color: #4ade80; }
|
||||
.dr-sz.del { color: #f87171; }
|
||||
.dr-sz.cat { color: #93c5fd; }
|
||||
|
||||
/* Interactive delete row states */
|
||||
.dr-row.del { cursor: default; }
|
||||
.dr-row.del:hover { background: rgba(248,113,113,.03); }
|
||||
.dr-row.del.confirmed { background: rgba(248,113,113,.08); }
|
||||
.dr-row.del.confirmed .dr-tag.del { background: rgba(248,113,113,.25); color: #fca5a5; border-color: rgba(248,113,113,.4); }
|
||||
.dr-row.del.confirmed .dr-path { text-decoration: line-through; text-decoration-color: #f87171; }
|
||||
.dr-row.del.done { opacity: 0.35; pointer-events: none; }
|
||||
.dr-row.del.done .dr-path { text-decoration: line-through; color: #555; }
|
||||
.dr-row.del.error .dr-tag.del { background: rgba(251,191,36,.12); color: #fbbf24; border-color: rgba(251,191,36,.25); }
|
||||
/* KEEP row — clickable to swap roles */
|
||||
.dr-row.keep { cursor: pointer; user-select: none; }
|
||||
.dr-row.keep:hover { background: rgba(248,113,113,.03); }
|
||||
|
||||
#dupe-review-execute { background: rgba(248,113,113,.15); border-color: rgba(248,113,113,.3); color: #fca5a5; }
|
||||
#dupe-review-execute:hover:not(:disabled) { background: rgba(248,113,113,.25); }
|
||||
#dupe-review-execute:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.dr-empty { padding: 20px 16px; color: #3a3a6a; font-size: 13px; }
|
||||
.dr-skipped { padding: 8px 16px 12px; font-size: 11px; color: #bbbbdd; }
|
||||
.dr-skipped-item { font-family: Consolas, monospace; font-size: 10px; color: #9999bb; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* Keep Ranking Panel */
|
||||
.kr-panel { background: rgba(96,165,250,.04); border: 1px solid rgba(96,165,250,.12); border-radius: 8px; padding: 14px 16px; margin-top: 10px; }
|
||||
.kr-info { font-size: 11px; color: #8888aa; margin-bottom: 12px; line-height: 1.5; }
|
||||
.kr-info code { background: rgba(255,255,255,.07); padding: 1px 5px; border-radius: 3px; font-family: Consolas, monospace; color: #93c5fd; }
|
||||
.kr-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||
.kr-label { font-size: 12px; color: #b0b0c8; width: 140px; flex-shrink: 0; }
|
||||
.kr-input { width: 70px; background: rgba(255,255,255,.06); border: 1px solid #2a2a3a; border-radius: 5px; color: #c0c0e0; font-size: 12px; padding: 4px 8px; }
|
||||
.kr-input:focus { outline: none; border-color: rgba(96,165,250,.4); }
|
||||
.kr-unit { font-size: 11px; color: #666; }
|
||||
.kr-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #b0b0c8; cursor: pointer; }
|
||||
.kr-toggle input[type=checkbox] { accent-color: #60a5fa; }
|
||||
.kr-fmt-list { display: flex; flex-direction: column; gap: 4px; min-width: 130px; }
|
||||
.kr-fmt-item { display: flex; align-items: center; gap: 6px; background: rgba(255,255,255,.04); border: 1px solid #2a2a3a; border-radius: 5px; padding: 5px 8px; cursor: grab; font-size: 12px; color: #c0c0e0; font-family: Consolas, monospace; user-select: none; }
|
||||
.kr-fmt-item.dragging { opacity: 0.4; }
|
||||
.kr-fmt-item.drag-over { border-color: rgba(96,165,250,.5); background: rgba(96,165,250,.08); }
|
||||
.kr-fmt-grip { color: #444; font-size: 10px; cursor: grab; }
|
||||
.kr-fmt-priority { font-size: 10px; color: #555; margin-left: auto; }
|
||||
.kr-save-row { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
|
||||
.kr-save-status { font-size: 11px; color: #8888aa; flex: 1; }
|
||||
.kr-save-status.ok { color: #6ee7b7; }
|
||||
.kr-save-status.err { color: #f87171; }
|
||||
|
||||
/* Library Issues modal */
|
||||
#library-issues-modal .modal { background: linear-gradient(135deg, #0f0f1a 0%, #0d1117 100%); border-color: #2a2a3a; width: 65vw; }
|
||||
#library-issues-modal .modal-head { background: linear-gradient(90deg, #12122a, #0d0d1a); border-bottom-color: #1e1e3a; }
|
||||
#library-issues-modal .modal-title { color: #e8e8ff; }
|
||||
#library-issues-modal .modal-subtitle { color: #8888aa; font-family: system-ui, sans-serif; }
|
||||
#library-issues-modal .modal-head button { color: #6060aa; border-color: #2a2a4a; }
|
||||
#library-issues-modal .modal-head button:hover { color: #b0b0d0; background: rgba(255,255,255,.05); }
|
||||
#library-issues-modal .modal-actions { border-top-color: #1a1a2a; background: rgba(0,0,0,.3); }
|
||||
#library-issues-modal .modal-actions button { background: rgba(255,255,255,.07); border-color: #3a3a5a; color: #c8c8e8; }
|
||||
#library-issues-modal .modal-actions button:hover { background: rgba(255,255,255,.13); }
|
||||
#library-issues-modal .modal-body { padding: 0; scrollbar-width: thin; scrollbar-color: #2a2a4a #0d0d1a; }
|
||||
#library-issues-modal .modal-body::-webkit-scrollbar { width: 6px; }
|
||||
#library-issues-modal .modal-body::-webkit-scrollbar-track { background: #0d0d1a; }
|
||||
#library-issues-modal .modal-body::-webkit-scrollbar-thumb { background: #2a2a4a; border-radius: 3px; }
|
||||
|
||||
/* Library issue rows */
|
||||
.li-section-head { padding: 10px 16px; font-size: 13px; font-weight: 600; color: #fbbf24; background: rgba(251,191,36,.06); border-bottom: 1px solid rgba(251,191,36,.12); }
|
||||
.li-section-head.with-filters { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||
.li-filter-group { display: flex; gap: 5px; flex-wrap: wrap; }
|
||||
.li-filter-chip {
|
||||
width: 82px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 3px 7px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(96,165,250,.22);
|
||||
background: rgba(96,165,250,.06);
|
||||
color: #93c5fd;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
cursor: pointer;
|
||||
}
|
||||
.li-type-chip { width: 132px; }
|
||||
.li-filter-chip.active { background: rgba(96,165,250,.18); border-color: rgba(96,165,250,.45); color: #bfdbfe; }
|
||||
.li-filter-chip:hover { border-color: rgba(96,165,250,.55); }
|
||||
.li-row { display: grid; grid-template-columns: auto 1fr auto auto; gap: 10px; align-items: center; padding: 10px 16px; border-bottom: 1px solid rgba(255,255,255,.04); }
|
||||
.li-row:hover { background: rgba(255,255,255,.03); }
|
||||
.li-tag { font-size: 12px; font-weight: 600; padding: 3px 9px; border-radius: 10px; white-space: nowrap; }
|
||||
.li-tag.bracket { background: rgba(251,191,36,.12); color: #fbbf24; border: 1px solid rgba(251,191,36,.25); }
|
||||
.li-tag.nohyphen { background: rgba(167,139,250,.12); color: #c4b5fd; border: 1px solid rgba(167,139,250,.25); }
|
||||
.li-tag.missingres { background: rgba(96,165,250,.12); color: #93c5fd; border: 1px solid rgba(96,165,250,.25); }
|
||||
.li-tag.noncanonres { background: rgba(251,146,60,.12); color: #fdba74; border: 1px solid rgba(251,146,60,.25); }
|
||||
.li-tag.done { background: rgba(74,222,128,.12); color: #4ade80; border: 1px solid rgba(74,222,128,.25); }
|
||||
.li-tag.conflict { background: rgba(248,113,113,.12); color: #f87171; border: 1px solid rgba(248,113,113,.25); }
|
||||
.li-section-sub { color: #8888aa; font-weight: 400; margin-left: 8px; }
|
||||
.li-names { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.li-old { font-family: Consolas, monospace; font-size: 13px; color: #8888aa; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.li-arrow { font-size: 11px; color: #4ade80; }
|
||||
.li-new { font-family: Consolas, monospace; font-size: 13px; color: #c0c0e0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.li-sz { font-size: 13px; color: #7f82d8; white-space: nowrap; }
|
||||
.li-action-note { font-size: 13px; color: #7f82d8; white-space: nowrap; }
|
||||
.li-rename-btn { font-size: 13px; padding: 4px 11px; border-radius: 5px; border: 1px solid #3a3a5a; background: rgba(255,255,255,.06); color: #c8c8e8; cursor: pointer; white-space: nowrap; }
|
||||
.li-rename-btn:hover:not(:disabled) { background: rgba(255,255,255,.12); }
|
||||
.li-rename-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.li-empty { padding: 24px 16px; color: #4ade80; font-size: 13px; }
|
||||
.li-stats { padding: 12px 16px; font-size: 14px; color: #a0a4c4; border-bottom: 1px solid rgba(255,255,255,.06); }
|
||||
.li-stats.with-filters { position: sticky; top: 0; z-index: 4; display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; background: #0d0d1a; }
|
||||
.li-stats b { color: #c8c8e8; }
|
||||
|
||||
/* Dupe Review filter bar */
|
||||
.dr-filter-bar { position: sticky; top: 0; z-index: 5; display: flex; flex-wrap: wrap; align-items: center; gap: 5px; padding: 9px 14px; background: #0d0d1a; border-bottom: 1px solid #1e1e2a; }
|
||||
.dr-filter-label { font-size: 11px; color: #555; padding: 0 2px; white-space: nowrap; }
|
||||
.dr-filter-sep { width: 1px; height: 16px; background: #2a2a3a; margin: 0 4px; flex-shrink: 0; }
|
||||
.dr-chip { font-size: 11px; padding: 3px 11px; border-radius: 20px; border: 1px solid #2a2a3a; background: rgba(255,255,255,.04); color: #888; cursor: pointer; transition: all .15s; font-family: system-ui, sans-serif; }
|
||||
.dr-chip:hover { border-color: #3a3a5a; color: #aaa; }
|
||||
.dr-search { font-size: 11px; padding: 3px 10px; border-radius: 20px; border: 1px solid #2a2a3a; background: rgba(255,255,255,.04); color: #c0c0e0; outline: none; width: 160px; font-family: Consolas, monospace; }
|
||||
.dr-search::placeholder { color: #444; }
|
||||
.dr-search:focus { border-color: rgba(96,165,250,.4); background: rgba(96,165,250,.05); }
|
||||
.dr-chip.active { border-color: rgba(96,165,250,.4); background: rgba(96,165,250,.12); color: #93c5fd; }
|
||||
.dr-card.dr-hidden { display: none; }
|
||||
|
||||
/* Variant alerts */
|
||||
.dr-variant-section { margin: 16px 0 0; }
|
||||
.dr-variant-heading { padding: 8px 16px; font-size: 12px; font-weight: 600; color: #fbbf24; background: rgba(251,191,36,.06); border-top: 1px solid rgba(251,191,36,.2); border-bottom: 1px solid rgba(251,191,36,.12); }
|
||||
.dr-card.variant-alert { border-color: rgba(251,191,36,.25); }
|
||||
.dr-card.variant-alert .dr-card-head { background: rgba(251,191,36,.06); }
|
||||
.dr-variant-label { font-size: 10px; font-weight: 500; color: #fbbf24; background: rgba(251,191,36,.12); border: 1px solid rgba(251,191,36,.25); padding: 1px 7px; border-radius: 10px; }
|
||||
.dr-tag.variant { background: rgba(251,191,36,.12); color: #fbbf24; border: 1px solid rgba(251,191,36,.25); }
|
||||
.dr-tag.bare { background: rgba(167,139,250,.12); color: #c4b5fd; border: 1px solid rgba(167,139,250,.25); }
|
||||
.dr-row.variant .dr-path { color: #c0c0e0; }
|
||||
@@ -0,0 +1,837 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>rclone-jav options</title>
|
||||
<link rel="stylesheet" href="options.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<div class="layout">
|
||||
|
||||
<!-- ============================== SIDEBAR ============================== -->
|
||||
<div class="side">
|
||||
<div class="brand">
|
||||
<strong>rclone-jav</strong>
|
||||
<span>Extension settings</span>
|
||||
</div>
|
||||
<div class="group">
|
||||
<div class="gtitle">Console</div>
|
||||
<div class="item active" data-pane="dupe-review"><span class="label">Duplicate Review</span><span class="side-badge" data-badge="dupe-count"></span></div>
|
||||
<div class="item" data-pane="search"><span class="label">Cache & Scans</span><span class="side-badge" data-badge="cache-age"></span></div>
|
||||
<div class="item" data-pane="library-issues"><span class="label">Library Issues</span><span class="side-badge" data-badge="library-issues-count"></span></div>
|
||||
<div class="side-note">Bulk Check lives in its own window — popup launcher, not sidebar.</div>
|
||||
</div>
|
||||
<div class="group">
|
||||
<div class="gtitle">Settings</div>
|
||||
<div class="item" data-pane="profiles"><span class="label">Profiles</span></div>
|
||||
<div class="item" data-pane="triggers"><span class="label">Scan Behavior</span></div>
|
||||
<div class="item" data-pane="normalizers"><span class="label">Matching Rules</span></div>
|
||||
<div class="item" data-pane="adapters"><span class="label">Site Extraction</span></div>
|
||||
<div class="item" data-pane="overlays"><span class="label">Overlays</span></div>
|
||||
<div class="item danger" data-pane="deletion"><span class="label">Deletion</span></div>
|
||||
</div>
|
||||
<div class="group">
|
||||
<div class="gtitle">Support</div>
|
||||
<div class="item" data-pane="paths"><span class="label">Setup</span></div>
|
||||
<div class="item" data-pane="diagnostics"><span class="label">Diagnostics</span></div>
|
||||
<div class="item" data-pane="debug"><span class="label">Debug Tools</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================== MAIN ============================== -->
|
||||
<div class="main">
|
||||
|
||||
<!-- TRIGGERS -->
|
||||
<div class="pane" id="pane-triggers">
|
||||
<div class="pane-head">
|
||||
<h1>Scan Behavior</h1>
|
||||
<div class="pdesc">Choose when rclone-jav checks the current page.</div>
|
||||
</div>
|
||||
<div id="trigger-summary" class="section-note"></div>
|
||||
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">Scope</div>
|
||||
<div class="setting-list">
|
||||
<label><input type="checkbox" id="autoEveryPage"> Auto-check every page load
|
||||
<span class="sublabel">Runs on every site. Badge shows "?" when no JAV ID detected.</span></label>
|
||||
|
||||
<label><input type="checkbox" id="autoKnownSites"> Auto-check on known JAV sites
|
||||
<span class="sublabel">Only fires on host patterns listed below.</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">Known site patterns</div>
|
||||
<div class="help">Comma- or newline-separated. Bare domain matches site + any subdomain (<code>clearjav.com</code> covers <code>www.clearjav.com</code>).</div>
|
||||
<textarea id="knownSitePatterns" placeholder="clearjav.com javdb.com" style="resize:none;overflow:hidden;field-sizing:content;min-height:60px;"></textarea>
|
||||
<div class="chip-row" style="margin-top:8px;">
|
||||
<button id="add-current-site" class="chip-btn" type="button">Add Current Site</button>
|
||||
<button id="add-clearjav-site" class="chip-btn" type="button">Add ClearJAV</button>
|
||||
<span id="known-site-status" class="muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">Navigation types</div>
|
||||
<div class="setting-list">
|
||||
<label><input type="checkbox" id="autoPageLoad"> Full page loads
|
||||
<span class="sublabel">Runs when the browser reports the page has finished loading.</span></label>
|
||||
<label><input type="checkbox" id="autoSpaNavigation"> SPA and history URL changes
|
||||
<span class="sublabel">Runs when a site changes URL without a full reload, including browser back/forward on some sites.</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">Manual controls</div>
|
||||
<div class="setting-list">
|
||||
<label><input type="checkbox" id="toolbarClick"> Toolbar icon popup
|
||||
<span class="sublabel">Click the rclone-jav icon to run a check on the active tab.</span></label>
|
||||
<label><input type="checkbox" id="contextMenu"> Right-click context menu</label>
|
||||
<label><input type="checkbox" id="keyboardShortcut"> Keyboard shortcut (Alt+J)
|
||||
<span class="sublabel">Rebind at <code>brave://extensions/shortcuts</code>.</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="primary save-btn">SAVE</button>
|
||||
<span class="saved">Saved.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OVERLAYS -->
|
||||
<div class="pane" id="pane-overlays">
|
||||
<div class="pane-head">
|
||||
<h1>Overlays</h1>
|
||||
<div class="pdesc">In-page toast notifications after auto-trigger scans.</div>
|
||||
</div>
|
||||
<div id="overlay-summary" class="section-note"></div>
|
||||
|
||||
<!-- Horizontal tabs -->
|
||||
<div class="overlay-tabs">
|
||||
<button class="otab active" data-otab="match">✓ Match</button>
|
||||
<button class="otab" data-otab="nomatch">✗ No Match</button>
|
||||
</div>
|
||||
|
||||
<!-- MATCH tab -->
|
||||
<div class="otab-panel active" id="otab-match">
|
||||
<label style="margin-bottom:10px;display:flex;align-items:center;gap:10px;">
|
||||
<input type="checkbox" id="showOverlay"> Show overlay on match
|
||||
<span class="sublabel" style="margin:0;">Hover to pause countdown. Auto-check only.</span>
|
||||
</label>
|
||||
|
||||
<div class="fieldset">
|
||||
<div style="display:flex;gap:14px;align-items:center;flex-wrap:wrap;">
|
||||
<div>
|
||||
<div style="font-size:11px;color:#888;margin-bottom:4px;">Position</div>
|
||||
<div class="radio-group" style="margin:0;">
|
||||
<label><input type="radio" name="overlayPosition" value="top-left"> Top-Left</label>
|
||||
<label><input type="radio" name="overlayPosition" value="top-right" checked> Top-Right</label>
|
||||
<label><input type="radio" name="overlayPosition" value="bottom-left"> Bottom-Left</label>
|
||||
<label><input type="radio" name="overlayPosition" value="bottom-right"> Bottom-Right</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:11px;color:#888;margin-bottom:4px;">Duration (sec)</div>
|
||||
<input type="number" id="overlayDuration" value="5" min="1" max="60" step="1" style="width:80px;">
|
||||
</div>
|
||||
</div>
|
||||
<label style="margin-top:10px;display:flex;align-items:center;gap:10px;">
|
||||
<input type="checkbox" id="overlayGlow"> Subtle glow
|
||||
<input type="color" id="overlayGlowColor" value="#6ec1ff" style="width:36px;height:24px;padding:0;cursor:pointer;" title="Glow color">
|
||||
</label>
|
||||
<div id="glow-detail" style="margin-top:10px;padding:10px;background:#101010;border:1px solid #232323;border-radius:4px;display:grid;grid-template-columns:90px 1fr 50px;gap:8px;align-items:center;font-size:11px;">
|
||||
<span style="color:#888;">Blur (soft)</span>
|
||||
<input type="range" id="overlayGlowBlur" min="0" max="60" step="1" value="10" style="width:100%;">
|
||||
<span id="overlayGlowBlurVal" style="color:#6ec1ff;text-align:right;font-family:Consolas,monospace;">10 px</span>
|
||||
<span style="color:#888;">Spread (size)</span>
|
||||
<input type="range" id="overlayGlowSpread" min="0" max="40" step="1" value="0" style="width:100%;">
|
||||
<span id="overlayGlowSpreadVal" style="color:#6ec1ff;text-align:right;font-family:Consolas,monospace;">0 px</span>
|
||||
<span style="color:#888;">Opacity</span>
|
||||
<input type="range" id="overlayGlowOpacity" min="0.05" max="1" step="0.05" value="0.35" style="width:100%;">
|
||||
<span id="overlayGlowOpacityVal" style="color:#6ec1ff;text-align:right;font-family:Consolas,monospace;">0.35</span>
|
||||
</div>
|
||||
<div style="margin-top:8px;display:flex;justify-content:flex-end;">
|
||||
<button id="overlay-reset" type="button" style="font-size:11px;padding:4px 10px;">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:12px;border-top:1px solid #222;padding-top:12px;">
|
||||
<div style="font-size:11px;color:#888;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;">
|
||||
<span>Preview <span id="overlay-preview-pos" style="color:#6ec1ff;">— top-right</span></span>
|
||||
<button id="overlay-preview-replay" type="button" style="font-size:11px;padding:2px 8px;">Replay</button>
|
||||
</div>
|
||||
<div id="overlay-preview-stage" style="background:#080808;border:1px dashed #2a2a2a;border-radius:4px;padding:18px;display:flex;justify-content:flex-end;align-items:flex-start;min-height:130px;position:relative;">
|
||||
<div id="overlay-preview" style="width:280px;background:#1a1a1a;color:#ddd;border:1px solid #2a2a2a;border-radius:6px;font-size:12px;overflow:hidden;box-shadow:0 6px 20px rgba(0,0,0,.55);">
|
||||
<div style="padding:6px 10px;background:#1e3a1e;color:#afa;font-weight:600;display:flex;align-items:center;gap:6px;font-size:12px;"><span>✓ IPZZ-860 — 1 hit(s)</span></div>
|
||||
<div style="padding:6px;background:#0d0d0d;">
|
||||
<div style="background:#161616;border:1px solid #2a2a2a;border-radius:4px;padding:8px 10px;">
|
||||
<div style="color:#fff;font-weight:600;font-size:12px;">IPZZ-860 [4K].mkv</div>
|
||||
<div style="color:#aaa;font-size:11px;font-family:Consolas,monospace;margin:3px 0;"><span style="color:#555;font-weight:600;">Path:</span> cq:JAV/ClearJAV/...</div>
|
||||
<div style="display:flex;align-items:center;gap:8px;font-size:10px;">
|
||||
<span style="background:#2a2a1a;color:#ffcc44;padding:1px 6px;border-radius:10px;font-weight:600;letter-spacing:.3px;">TARGET</span>
|
||||
<span style="color:#6ec1ff;font-weight:600;">5.93 GiB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:3px;background:#222;"><div id="overlay-preview-bar" style="height:100%;background:linear-gradient(90deg,#6ec1ff,#66dd66);transform-origin:left;transform:scaleX(1);"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NO MATCH tab -->
|
||||
<div class="otab-panel" id="otab-nomatch">
|
||||
<label style="margin-bottom:10px;display:flex;align-items:center;gap:10px;">
|
||||
<input type="checkbox" id="noMatchOverlay"> Show overlay on no match
|
||||
<span class="sublabel" style="margin:0;">Off by default. Auto-check only.</span>
|
||||
</label>
|
||||
|
||||
<div class="fieldset" id="no-match-fieldset">
|
||||
<div style="display:flex;gap:14px;align-items:center;flex-wrap:wrap;">
|
||||
<div>
|
||||
<div style="font-size:11px;color:#888;margin-bottom:4px;">Position</div>
|
||||
<div class="radio-group" id="noMatchPositionGroup" style="margin:0;">
|
||||
<label><input type="radio" name="noMatchPosition" value="top-left"> Top-Left</label>
|
||||
<label><input type="radio" name="noMatchPosition" value="top-right" checked> Top-Right</label>
|
||||
<label><input type="radio" name="noMatchPosition" value="bottom-left"> Bottom-Left</label>
|
||||
<label><input type="radio" name="noMatchPosition" value="bottom-right"> Bottom-Right</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:11px;color:#888;margin-bottom:4px;">Duration (sec)</div>
|
||||
<input type="number" id="noMatchDuration" value="5" min="1" max="60" step="1" style="width:80px;">
|
||||
</div>
|
||||
</div>
|
||||
<label style="margin-top:10px;display:flex;align-items:center;gap:10px;">
|
||||
<input type="checkbox" id="noMatchGlow"> Subtle glow
|
||||
<input type="color" id="noMatchGlowColor" value="#ff6666" style="width:36px;height:24px;padding:0;cursor:pointer;" title="Glow color">
|
||||
</label>
|
||||
<div style="margin-top:10px;padding:10px;background:#101010;border:1px solid #232323;border-radius:4px;display:grid;grid-template-columns:90px 1fr 50px;gap:8px;align-items:center;font-size:11px;">
|
||||
<span style="color:#888;">Blur (soft)</span>
|
||||
<input type="range" id="noMatchGlowBlur" min="0" max="60" step="1" value="10" style="width:100%;">
|
||||
<span id="noMatchGlowBlurVal" style="color:#ff6666;text-align:right;font-family:Consolas,monospace;">10 px</span>
|
||||
<span style="color:#888;">Spread (size)</span>
|
||||
<input type="range" id="noMatchGlowSpread" min="0" max="40" step="1" value="0" style="width:100%;">
|
||||
<span id="noMatchGlowSpreadVal" style="color:#ff6666;text-align:right;font-family:Consolas,monospace;">0 px</span>
|
||||
<span style="color:#888;">Opacity</span>
|
||||
<input type="range" id="noMatchGlowOpacity" min="0.05" max="1" step="0.05" value="0.35" style="width:100%;">
|
||||
<span id="noMatchGlowOpacityVal" style="color:#ff6666;text-align:right;font-family:Consolas,monospace;">0.35</span>
|
||||
</div>
|
||||
<div style="margin-top:8px;display:flex;justify-content:flex-end;">
|
||||
<button id="no-match-reset" type="button" style="font-size:11px;padding:4px 10px;">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:12px;border-top:1px solid #222;padding-top:12px;">
|
||||
<div style="font-size:11px;color:#888;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center;">
|
||||
<span>Preview <span id="no-match-preview-pos" style="color:#ff6666;">— top-right</span></span>
|
||||
<button id="no-match-preview-replay" type="button" style="font-size:11px;padding:2px 8px;">Replay</button>
|
||||
</div>
|
||||
<div id="no-match-preview-stage" style="background:#080808;border:1px dashed #2a2a2a;border-radius:4px;padding:18px;display:flex;justify-content:flex-end;align-items:flex-start;min-height:90px;position:relative;">
|
||||
<div id="no-match-preview" style="width:280px;background:#1a1a1a;color:#ddd;border:1px solid #2a2a2a;border-radius:6px;font-size:12px;overflow:hidden;box-shadow:0 6px 20px rgba(0,0,0,.55);">
|
||||
<div style="padding:6px 10px;background:#3a1e1e;color:#faa;font-weight:600;font-size:12px;">✗ START-489 — NOT IN LIBRARY</div>
|
||||
<div style="padding:8px 12px;background:#0d0d0d;color:#aaa;font-size:11px;font-family:Consolas,monospace;word-break:break-all;"><span style="color:#555;font-weight:600;">Scanned:</span> cq:JAV</div>
|
||||
<div style="height:3px;background:#222;"><div id="no-match-preview-bar" style="height:100%;background:linear-gradient(90deg,#ff6666,#ff8c42);transform-origin:left;transform:scaleX(1);"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary save-btn">SAVE</button>
|
||||
<span class="saved">Saved.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SITE ADAPTERS -->
|
||||
<div class="pane" id="pane-adapters">
|
||||
<div class="pane-head">
|
||||
<h1>Site Extraction</h1>
|
||||
<div class="pdesc">For sites where the JAV ID isn't in the page title, point at a DOM element. Selector runs <code>document.querySelector</code> and extracts <code>textContent</code>.</div>
|
||||
</div>
|
||||
<div id="adapter-summary" class="section-note"></div>
|
||||
|
||||
<table class="adapters" id="adapters">
|
||||
<thead><tr><th>Host pattern</th><th>CSS selector(s)</th><th></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<div class="fieldset" style="margin-top:12px;">
|
||||
<div class="ftitle">Built-in presets</div>
|
||||
<div style="font-family:Consolas,monospace;font-size:11px;color:#888;line-height:1.5;word-break:break-all;">
|
||||
<div><span style="color:#aaa;">clearjav.com</span> → <code>div.meta-chip > h3.meta-chip__value</code></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px;margin-top:10px;align-items:center;">
|
||||
<button id="add-adapter">+ Add Row</button>
|
||||
<button id="pick-element">Pick Element</button>
|
||||
<button id="test-active-page">Test Active Page</button>
|
||||
<button id="validate-adapters" type="button">Validate Rows</button>
|
||||
<span id="picker-status" style="color:#888;font-size:11px;"></span>
|
||||
</div>
|
||||
|
||||
<div id="adapter-test-result" class="fieldset" style="display:none;margin-top:14px;">
|
||||
<div class="ftitle">Active page extraction bench</div>
|
||||
<div id="adapter-test-output" style="font-family:Consolas,monospace;font-size:11px;color:#aaa;line-height:1.5;word-break:break-all;"></div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary save-btn">SAVE</button>
|
||||
<span class="saved">Saved.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MATCHING RULES -->
|
||||
<div class="pane" id="pane-normalizers">
|
||||
<div class="pane-head">
|
||||
<h1>Matching Rules</h1>
|
||||
<div class="pdesc">Normalize odd IDs and teach rc-jav how multipart filename suffixes should stay distinct.</div>
|
||||
</div>
|
||||
<div id="normalizer-summary" class="section-note"></div>
|
||||
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">Built-in ID rules</div>
|
||||
<div style="font-size:11px;color:#888;line-height:1.5;">
|
||||
<div><code>FC2-?PPV-?(\d{4,})</code> → <code>FC2-PPV-$1</code>
|
||||
<span style="color:#666;"> — handles <code>FC2PPV4903171</code>, <code>FC2-PPV4903171</code>, <code>FC2-PPV-4903171</code></span></div>
|
||||
<div><code>FC2-(\d{4,})</code> → <code>FC2-PPV-$1</code>
|
||||
<span style="color:#666;"> — handles bare site labels like <code>FC2-1841460</code></span></div>
|
||||
<div><code>_A</code> / <code>_B</code> multipart suffixes are distinct parts in rc-jav duplicate/cache ID detection.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fieldset" style="margin-top:14px;">
|
||||
<div class="ftitle">Test ID extraction</div>
|
||||
<div class="help">Paste a page title, DOM text, URL fragment, or filename. Uses the current custom normalizer rows below plus built-in extraction rules.</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 120px;gap:8px;align-items:center;">
|
||||
<input type="text" id="norm-test-in" placeholder="paste a sample page title or filename">
|
||||
<button id="norm-test-run">Test Text</button>
|
||||
</div>
|
||||
<div id="norm-test-out" style="margin-top:8px;font-family:Consolas,monospace;font-size:12px;color:#aaa;"></div>
|
||||
</div>
|
||||
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">Custom ID normalizers</div>
|
||||
<table class="adapters" id="normalizers">
|
||||
<thead><tr><th>Pattern (regex)</th><th>Replacement</th><th></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<div style="display:flex;gap:8px;margin-top:10px;align-items:center;">
|
||||
<button id="add-normalizer">+ Add Row</button>
|
||||
<button id="validate-normalizers" type="button">Validate Regex</button>
|
||||
<span id="normalizer-status" class="muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">Part detectors</div>
|
||||
<div class="help">Built-in and custom Python regexes run against the filename stem. Capture group 1 must be the part number or a single part letter. Built-ins are shown here so you can see what is already covered before adding a custom detector.</div>
|
||||
<div class="muted" style="margin:0 0 7px;">Built in</div>
|
||||
<div id="builtin-part-detectors" class="part-detector-list" style="margin-bottom:12px;"></div>
|
||||
<div class="muted" style="margin:0 0 7px;">Custom</div>
|
||||
<div id="part-detectors" class="part-detector-list"></div>
|
||||
<div class="button-row" style="margin-top:9px;">
|
||||
<button id="add-part-detector" type="button">+ Add Detector</button>
|
||||
</div>
|
||||
<div class="help">Host searches and cache rebuilds receive these saved rules. Direct CLI use can store the same list as <code>part_patterns</code> in <code>config.json</code> or pass repeatable <code>--part-pattern</code>.</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary save-btn">SAVE</button>
|
||||
<span class="saved">Saved.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CACHE & SCANS -->
|
||||
<div class="pane" id="pane-search">
|
||||
<div class="pane-head">
|
||||
<h1>Cache & Scans</h1>
|
||||
<div class="pdesc">Choose lookup mode, inspect cache state, and run cache rebuild jobs.</div>
|
||||
</div>
|
||||
<div id="search-summary" class="section-note"></div>
|
||||
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">Lookup mode</div>
|
||||
<div class="help">Quick mode bypasses cache and uses <code>rclone --include</code> directly — fastest for single-ID lookups. ~1–2s per call. Disable to use the cache (faster on warm cache, requires <code>--scan</code> to keep fresh).</div>
|
||||
<label><input type="checkbox" id="quickMode"> Use --quick mode (live rclone lookup, no cache)</label>
|
||||
</div>
|
||||
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">Cache status</div>
|
||||
<div class="help">Inspect the current <code>cache.json</code> remotes, scan times, file counts, and skipped filename samples.</div>
|
||||
<div class="cache-freshness">
|
||||
<label>Stale after
|
||||
<input type="number" id="cacheStaleHours" value="24" min="1" max="8760" step="1">
|
||||
hours
|
||||
</label>
|
||||
<span class="sublabel">Used for cache warnings in the extension. Freshness does not change search results.</span>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="cache-status-run" type="button">Check Cache</button>
|
||||
</div>
|
||||
<div id="cache-status-results" class="mono-output"></div>
|
||||
</div>
|
||||
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">Cache rebuild</div>
|
||||
<div class="help">Refresh the cache from the configured scan roots. Incremental rebuilds only ask rclone for recently changed files.</div>
|
||||
<div class="button-row">
|
||||
<select id="cache-rebuild-mode">
|
||||
<option value="">Full Rebuild</option>
|
||||
<option value="24h">Update last 24h</option>
|
||||
<option value="7d">Update last 7d</option>
|
||||
</select>
|
||||
<button id="cache-rebuild-run" type="button">Rebuild Cache</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">Scan job</div>
|
||||
<div class="help">Follow the current or last cache scan job and clear old job history after it is no longer useful.</div>
|
||||
<div class="button-row">
|
||||
<button id="scan-job-clear" type="button" style="margin-left:auto;">Clear Job</button>
|
||||
</div>
|
||||
<div id="scan-job-results" class="mono-output"></div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary save-btn">SAVE</button>
|
||||
<span class="saved">Saved.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DUPLICATE REVIEW -->
|
||||
<div class="pane active" id="pane-dupe-review">
|
||||
<div class="pane-head">
|
||||
<h1>Duplicate Review</h1>
|
||||
<div class="pdesc">Review cached duplicate groups and tune the KEEP ranking that picks the surviving file.</div>
|
||||
</div>
|
||||
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">Duplicate review</div>
|
||||
<div class="help">Review cached duplicate groups before acting. Keep/delete suggestions follow rc-jav rules; catalog rows remain reference-only.</div>
|
||||
<div class="button-row">
|
||||
<button id="dupe-review-run" type="button">Review Cached Duplicates</button>
|
||||
</div>
|
||||
<div id="dupe-review-results" class="mono-output">Review opens in a focused window so large duplicate groups do not stretch this page.</div>
|
||||
</div>
|
||||
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">Keep ranking</div>
|
||||
<div class="help">Controls how rc-jav picks the KEEP file when duplicates are found. Changes take effect immediately — no rescan required.</div>
|
||||
<div class="kr-panel">
|
||||
<div class="kr-info">
|
||||
<strong style="color:#93c5fd">How it works:</strong> Video files in <code>VIP folders</code> win first. <code>.ts</code> files rank below other video containers. After that, files within <code>size tolerance</code> of the largest are treated as equal-size, then ranked by
|
||||
<code>format preference</code>. Remaining ties broken by resolution tag, then filename length.
|
||||
With tolerance = 0 (default), size always decides first.
|
||||
</div>
|
||||
<div class="kr-row" style="align-items:flex-start">
|
||||
<span class="kr-label" style="padding-top:6px">VIP folders</span>
|
||||
<div>
|
||||
<div id="kr-vip-list" class="kr-fmt-list"></div>
|
||||
<div class="button-row" style="margin-top:6px;">
|
||||
<input id="kr-vip-add" type="text" placeholder="ClearJAV or cq:JAV/DirectRips" style="width:220px;">
|
||||
<button id="kr-vip-add-btn" type="button">Add VIP Folder</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="kr-unit" style="padding-top:6px">drag to reorder · top wins first · ClearJAV is the default direct-rip VIP folder</span>
|
||||
</div>
|
||||
<div class="kr-row">
|
||||
<span class="kr-label">Size tolerance</span>
|
||||
<input id="kr-tolerance" class="kr-input" type="number" min="0" max="9999" step="1" value="0">
|
||||
<span class="kr-unit">MiB — files within this range of the largest are considered equal-size</span>
|
||||
</div>
|
||||
<div class="kr-row" style="align-items:flex-start">
|
||||
<span class="kr-label" style="padding-top:6px">Format preference</span>
|
||||
<div id="kr-fmt-list" class="kr-fmt-list">
|
||||
<!-- populated by JS -->
|
||||
</div>
|
||||
<span class="kr-unit" style="padding-top:6px">drag to reorder · top = highest priority</span>
|
||||
</div>
|
||||
<div class="kr-row">
|
||||
<span class="kr-label">Tie-breaks</span>
|
||||
<label class="kr-toggle"><input type="checkbox" id="kr-res-tag" checked> Prefer file with resolution tag <code>[1080p]</code></label>
|
||||
</div>
|
||||
<div class="kr-row">
|
||||
<span class="kr-label"></span>
|
||||
<label class="kr-toggle"><input type="checkbox" id="kr-longer-name" checked> Prefer longer filename (more metadata)</label>
|
||||
</div>
|
||||
<div class="kr-save-row">
|
||||
<button id="kr-save" type="button">Save Ranking</button>
|
||||
<span id="kr-save-status" class="kr-save-status"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- LIBRARY ISSUES -->
|
||||
<div class="pane" id="pane-library-issues">
|
||||
<div class="pane-head">
|
||||
<h1>Library Issues</h1>
|
||||
<div class="pdesc">Find cache-only filename hygiene issues. Rename canonical-name fixes now; review missing resolution tags for later processing.</div>
|
||||
</div>
|
||||
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">Library issues</div>
|
||||
<div class="help">Find files with non-canonical names and missing final resolution tags like <code>BLK-474.mp4</code>. Rename suggestions are computed from cache — no network required.</div>
|
||||
<div class="button-row">
|
||||
<button id="library-issues-run" type="button">Review Library Issues</button>
|
||||
</div>
|
||||
<div id="library-issues-results" class="mono-output"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SETUP (orphaned — not in sidebar; DOM kept so JS IDs resolve) -->
|
||||
<div class="pane" id="pane-paths">
|
||||
<div class="pane-head">
|
||||
<h1>Setup</h1>
|
||||
<div class="pdesc">Script location and settings backup. Native host registration + extension ID live in Diagnostics.</div>
|
||||
</div>
|
||||
<div id="paths-summary" class="section-note"></div>
|
||||
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">rc-jav script path</div>
|
||||
<div class="help">Folder containing <code>rc-jav.py</code>, or full file path. Leave blank for the host default.</div>
|
||||
<div class="compact-grid">
|
||||
<input type="text" id="rcjavPath" placeholder="D:\DEV\Project\rclone-jav">
|
||||
<button id="clear-rcjav-path" type="button">Use Default</button>
|
||||
</div>
|
||||
<div class="button-row" style="margin-top:8px;">
|
||||
<button id="check-rcjav-path" type="button">Check Path</button>
|
||||
<span id="path-check-output" class="muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">Alerts</div>
|
||||
<div class="help">Send a Discord webhook on native-host errors (disconnects, timeouts, exceptions). Rate-limited to 1 alert per 10 minutes — same as the Windows notification. Leave URL blank to disable.</div>
|
||||
<div class="compact-grid">
|
||||
<input type="text" id="discordWebhookUrl" placeholder="https://discord.com/api/webhooks/...">
|
||||
<button id="test-discord-webhook" type="button" title="Sends a test alert from the extension's background script (browser network path)">Test (extension)</button>
|
||||
</div>
|
||||
<div class="button-row" style="margin-top:6px;">
|
||||
<button id="test-discord-host" type="button" title="Sends a test alert from the native host (Python urllib path). Verifies the host can post independently of the extension.">Test (host)</button>
|
||||
<span class="muted" style="font-size:11px;">Both paths fire on real errors — test each to confirm Discord receives them.</span>
|
||||
</div>
|
||||
<div style="margin-top:6px;font-size:11px;color:#888;">PC label <span class="muted">(optional, embedded in alerts so you can tell which PC fired)</span></div>
|
||||
<input type="text" id="pcLabel" placeholder="desktop · laptop · etc." style="width:240px;">
|
||||
<div class="button-row" style="margin-top:8px;">
|
||||
<span id="discord-status" class="muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="backup-summary" class="section-note"></div>
|
||||
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">Backup before moving</div>
|
||||
<div class="help">Extension settings and native messaging permissions are tied to the extension ID. Export before moving, reloading, or reinstalling. See <code>docs/EXTENSION_ID.md</code> for the stable-ID workflow.</div>
|
||||
<div class="button-row">
|
||||
<button id="export-settings">Export Settings</button>
|
||||
<button id="import-settings">Import Settings</button>
|
||||
<input type="file" id="import-file" accept=".json,application/json" style="display:none;">
|
||||
<span id="backup-status" style="color:#888;font-size:11px;"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary save-btn">SAVE</button>
|
||||
<span class="saved">Saved.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PROFILES -->
|
||||
<div class="pane" id="pane-profiles">
|
||||
<div class="pane-head">
|
||||
<h1>Library Profiles</h1>
|
||||
<div class="pdesc">Named source and target remote sets for switching libraries from the popup.</div>
|
||||
</div>
|
||||
<div id="profiles-summary" class="section-note"></div>
|
||||
|
||||
<div id="profiles-list" style="margin-bottom:12px;"></div>
|
||||
<div class="button-row">
|
||||
<button id="add-profile" type="button">+ Add Profile</button>
|
||||
<button id="load-remotes" type="button">Load Remotes</button>
|
||||
<span id="profiles-status" class="muted"></span>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary save-btn">SAVE</button>
|
||||
<span class="saved">Saved.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DIAGNOSTICS -->
|
||||
<div class="pane" id="pane-diagnostics">
|
||||
<div class="pane-head">
|
||||
<h1>Diagnostics</h1>
|
||||
<div class="pdesc">Check setup health, runtime dependencies, native host registration, and recent search behavior.</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">Setup health</div>
|
||||
<div class="help">Quick read on current lookup mode, pause state, active profile, cache freshness, and native host registration.</div>
|
||||
<div class="button-row">
|
||||
<button id="setup-health-run" type="button">Check Setup Health</button>
|
||||
</div>
|
||||
<div id="setup-health-results" class="mono-output"></div>
|
||||
</div>
|
||||
|
||||
<div id="native-repair-card" class="fieldset" style="display:none;">
|
||||
<div class="ftitle" id="native-repair-title">Native host setup</div>
|
||||
<div id="native-repair-results"></div>
|
||||
</div>
|
||||
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">Runtime</div>
|
||||
<div class="help">Verifies Python, rclone, rc-jav, config, cache, and WinCatalog files.</div>
|
||||
<div class="button-row">
|
||||
<button id="run-diag">Run Diagnostics</button>
|
||||
<button id="run-all-diag" type="button">Run All</button>
|
||||
</div>
|
||||
<div id="diag-results" style="margin-top:14px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">Native messaging log</div>
|
||||
<div class="help">Last 200 RPC calls to the native host — action, latency, response size, and any error/disconnect reason. Use this when "Error when communicating with the native messaging host" shows up in Check Library or the popup.</div>
|
||||
<div class="button-row">
|
||||
<button id="native-log-run" type="button">Refresh</button>
|
||||
<button id="native-log-clear" type="button" title="Clears the extension-side RPC log shown above (chrome.storage)">Clear Log</button>
|
||||
<button id="host-events-clear" type="button" title="Truncates host/logs/rcjav-host-events.log on disk">Clear Host Events</button>
|
||||
<label style="font-size:11px;color:#888;display:inline-flex;align-items:center;gap:6px;margin-left:auto;"><input type="checkbox" id="native-log-errors-only"> errors only</label>
|
||||
</div>
|
||||
<div id="native-log-results" class="mono-output"></div>
|
||||
</div>
|
||||
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">Native host registration</div>
|
||||
<div class="help">Checks the manifest, extension ID permission, and Windows registry entries used by Brave/Chrome native messaging.</div>
|
||||
<div class="compact-grid" style="margin-bottom:10px;align-items:center;">
|
||||
<div style="font-family:Consolas,monospace;font-size:12px;color:#bbb;"><span style="color:#888;">Extension ID: </span><span id="diag-extension-id">—</span></div>
|
||||
<button id="diag-copy-extension-id" type="button" style="padding:4px 10px;font-size:11px;">Copy ID</button>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="host-status-run" type="button">Check Host Registration</button>
|
||||
<button id="host-repair-run" type="button">Repair Registration</button>
|
||||
<button id="host-verify-run" type="button">Verify Registration</button>
|
||||
</div>
|
||||
<div id="host-status-results" style="margin-top:14px;"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DEBUG TOOLS -->
|
||||
<div class="pane" id="pane-debug">
|
||||
<div class="pane-head">
|
||||
<h1>Debug Tools</h1>
|
||||
<div class="pdesc">Standalone diagnostic surfaces moved out of the main Diagnostics pane. Search troubleshooting bench + recent search/page-check activity log. Not part of the maintenance workflow.</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">Search troubleshooting</div>
|
||||
<div class="help">Paste an ID, page title, DOM text, URL fragment, or filename. The bench extracts an ID, then compares LIVE and CACHE lookup paths for that ID under the active library profile.</div>
|
||||
<textarea id="search-bench-input" placeholder="BLK-474 or paste a page title / DOM text / URL containing an ID"></textarea>
|
||||
<div class="button-row" style="margin-top:8px;">
|
||||
<button id="search-bench-run" type="button">Test Search</button>
|
||||
<button id="search-bench-clear" type="button">Clear</button>
|
||||
</div>
|
||||
<div id="search-bench-results" class="mono-output"></div>
|
||||
</div>
|
||||
|
||||
<div class="fieldset">
|
||||
<div class="ftitle">Recent activity</div>
|
||||
<div class="help">Short local history of page checks and popup searches. Stores the extracted ID, outcome, page context, mode, and timing summary. Search-trigger events only — deletion actions are not recorded here.</div>
|
||||
<div class="button-row">
|
||||
<button id="activity-refresh" type="button">Refresh Activity</button>
|
||||
<button id="activity-clear" type="button">Clear Activity</button>
|
||||
</div>
|
||||
<div class="activity-filters" id="activity-filters" aria-label="Recent activity filters">
|
||||
<button class="activity-filter af-all active" type="button" data-activity-filter="all"><span class="af-lbl">All</span><span class="af-cnt" data-activity-count="all">0</span></button>
|
||||
<button class="activity-filter af-hit" type="button" data-activity-filter="hit"><span class="af-lbl">Match</span><span class="af-cnt" data-activity-count="hit">0</span></button>
|
||||
<button class="activity-filter af-miss" type="button" data-activity-filter="miss"><span class="af-lbl">No Match</span><span class="af-cnt" data-activity-count="miss">0</span></button>
|
||||
<button class="activity-filter af-other" type="button" data-activity-filter="other"><span class="af-lbl">Other</span><span class="af-cnt" data-activity-count="other">0</span></button>
|
||||
</div>
|
||||
<div id="activity-results" class="mono-output"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DELETION -->
|
||||
<div class="pane" id="pane-deletion">
|
||||
<div class="pane-head">
|
||||
<h1 style="color:#faa;">Deletion</h1>
|
||||
<div class="pdesc">When enabled, the popup adds a delete button for matched files. Every delete still requires typing the exact filename to confirm.</div>
|
||||
</div>
|
||||
<div id="deletion-summary" class="section-note danger"></div>
|
||||
|
||||
<div class="fieldset danger-zone">
|
||||
<div class="ftitle">Enable</div>
|
||||
<label><input type="checkbox" id="enableDelete"> Enable file deletion from popup</label>
|
||||
</div>
|
||||
|
||||
<div class="fieldset danger-zone">
|
||||
<div class="ftitle">Mode</div>
|
||||
<div class="radio-group">
|
||||
<label class="selected" id="deleteModeTrashLbl"><input type="radio" name="deleteMode" id="deleteModeTrash" value="trash" checked> Move To Trash</label>
|
||||
<label id="deleteModePermLbl"><input type="radio" name="deleteMode" id="deleteModePerm" value="permanent"> Permanent Delete</label>
|
||||
</div>
|
||||
<div class="help">Trash mode runs <code>rclone moveto</code> into the trash dir + <code>YYYY-MM-DD/</code> subdir. Reversible until cleared. Permanent runs <code>rclone deletefile</code> — cloud Drives may still keep a 30-day recycle bin.</div>
|
||||
</div>
|
||||
|
||||
<div class="fieldset danger-zone">
|
||||
<div class="ftitle">Trash directory</div>
|
||||
<input type="text" id="trashDir" placeholder="cq:personal-files/.rclone-jav-trash">
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="primary save-btn">SAVE</button>
|
||||
<span class="saved">Saved.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="skipped-modal" class="modal-backdrop" aria-hidden="true">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="skipped-modal-title">
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<div class="modal-title" id="skipped-modal-title">Skipped IDs</div>
|
||||
<div class="modal-subtitle" id="skipped-modal-subtitle"></div>
|
||||
</div>
|
||||
<button id="skipped-modal-close" type="button" title="Close">x</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="skipped-modal-summary" class="skip-summary"></div>
|
||||
<div id="skipped-modal-list"></div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="skipped-modal-copy" type="button">Copy List</button>
|
||||
<button id="skipped-modal-done" type="button">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="profile-modal" class="modal-backdrop" aria-hidden="true">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="profile-modal-title">
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<div class="modal-title" id="profile-modal-title">Library Profile</div>
|
||||
<div class="modal-subtitle">Named source and target remotes for popup library switching.</div>
|
||||
</div>
|
||||
<button id="profile-modal-close" type="button" title="Close">x</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-field">
|
||||
<label for="profile-modal-name">Profile name</label>
|
||||
<input type="text" id="profile-modal-name" placeholder="ClearJAV">
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>Source remotes</label>
|
||||
<div id="profile-modal-source" class="prof-remote-group"></div>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>Target remotes</label>
|
||||
<div id="profile-modal-target" class="prof-remote-group"></div>
|
||||
</div>
|
||||
<div id="profile-modal-status" class="muted"></div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="profile-modal-cancel" type="button">Cancel</button>
|
||||
<button id="profile-modal-save" class="primary" type="button">Save Profile</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dupe-review-modal" class="modal-backdrop" aria-hidden="true">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="dupe-review-modal-title">
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<div class="modal-title" id="dupe-review-modal-title">Duplicate Review</div>
|
||||
<div class="modal-subtitle">Cached duplicate groups and rc-jav keep/delete suggestions.</div>
|
||||
</div>
|
||||
<button id="dupe-review-modal-close" type="button" title="Close">x</button>
|
||||
</div>
|
||||
<div id="dupe-review-modal-body" class="modal-body"></div>
|
||||
<div class="modal-actions">
|
||||
<span id="dupe-review-confirm-status" style="font-size:12px;color:#8888aa;flex:1;"></span>
|
||||
<button id="dupe-review-execute" type="button" disabled>Execute Deletions (0)</button>
|
||||
<button id="dupe-review-export" type="button" disabled>Export JSON</button>
|
||||
<button id="dupe-review-modal-done" type="button">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="library-issues-modal" class="modal-backdrop" aria-hidden="true">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="library-issues-modal-title">
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<div class="modal-title" id="library-issues-modal-title">Library Issues</div>
|
||||
<div class="modal-subtitle">Filename hygiene issues detected in cache.</div>
|
||||
</div>
|
||||
<button id="library-issues-modal-close" type="button" title="Close">x</button>
|
||||
</div>
|
||||
<div id="library-issues-modal-body" class="modal-body"></div>
|
||||
<div class="modal-actions">
|
||||
<span id="library-issues-rename-status" style="font-size:12px;color:#8888aa;flex:1;"></span>
|
||||
<button id="library-issues-rename-all" type="button" disabled>Rename ID Fixes</button>
|
||||
<button id="library-issues-export" type="button" disabled>Export JSON</button>
|
||||
<button id="library-issues-modal-done" type="button">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="import-modal" class="modal-backdrop" aria-hidden="true">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="import-modal-title">
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<div class="modal-title" id="import-modal-title">Import Settings</div>
|
||||
<div class="modal-subtitle" id="import-modal-subtitle"></div>
|
||||
</div>
|
||||
<button id="import-modal-close" type="button" title="Close">x</button>
|
||||
</div>
|
||||
<div id="import-modal-body" class="modal-body"></div>
|
||||
<div class="modal-actions">
|
||||
<button id="import-modal-cancel" type="button">Cancel</button>
|
||||
<button id="import-modal-confirm" class="primary" type="button">Import Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="delete-enable-modal" class="modal-backdrop" aria-hidden="true">
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="delete-enable-modal-title">
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<div class="modal-title" id="delete-enable-modal-title">Enable Deletion</div>
|
||||
<div class="modal-subtitle">This exposes delete controls in the popup.</div>
|
||||
</div>
|
||||
<button id="delete-enable-modal-close" type="button" title="Close">x</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-confirm">Matched files can be moved to trash or permanently deleted from the popup after this setting is saved. Popup deletion still requires typing the exact filename before rc-jav acts.</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="delete-enable-modal-cancel" type="button">Cancel</button>
|
||||
<button id="delete-enable-modal-confirm" class="primary" type="button">Enable Deletion</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="options-shared.js"></script>
|
||||
<script src="options-cache.js"></script>
|
||||
<script src="options-dupe-review.js"></script>
|
||||
<script src="options-library-issues.js"></script>
|
||||
<script src="options-diagnostics.js"></script>
|
||||
<script src="options-profiles.js"></script>
|
||||
<script src="options-rules-editors.js"></script>
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user