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:
admin
2026-05-26 22:42:15 +02:00
parent 0e230320a9
commit 2d6a95682f
29 changed files with 2360 additions and 765 deletions
+4
View File
@@ -1,3 +1,7 @@
host/__pycache__/
host/logs/
host/state/
*.bak
rclone-jav-library-issues-*.json
.vscode/
.idea/
-122
View File
@@ -1,122 +0,0 @@
# rclone-jav (Brave extension + native messaging host)
Session memory for Claude. Read before making changes here.
## Architecture
```
Brave tab title -> content script extracts JAV ID
-> background.js connectNative("com.rcjav.host")
-> host/rcjav-host.bat (portable: py launcher or python on PATH)
-> host/rcjav-host.py
-> subprocess python rc-jav.py --search ID --basic --no-color --format json
-> structured hits back through native port
-> popup or in-page overlay
```
Two separate codebases:
- This repo: Brave extension + native messaging host.
- `D:\DEV\Project\rclone-jav\` — Python rc-jav CLI. The host shells out to `rc-jav.py` here.
## Folder layout (post-rename)
```
D:\DEV\Extensions\Production\rclone-jav\ (PC 1)
D:\DEV\Extensions\Staging\rclone-jav\ (PC 2)
├── manifest.json
├── background.js
├── content.js
├── popup.{html,js,css}
├── options.{html,js}
├── host\
│ ├── rcjav-host.py
│ ├── rcjav-host.bat (portable: py launcher fallback)
│ ├── install-host.ps1 (self-elevates to HKLM)
│ ├── register-host.bat (prompts for ID, calls install-host.ps1)
│ ├── com.rcjav.host.json (generated; UTF-8 NO BOM)
│ └── (logs)
└── docs\
├── INSTALL.md (gotcha table at the bottom)
└── README.md
```
## Critical gotchas (learned the hard way)
| Symptom | Cause | Fix |
|---|---|---|
| "Specified native messaging host not found" | UTF-8 BOM in com.rcjav.host.json | `WriteAllText` with `UTF8Encoding($false)` |
| Same error after registering HKCU | Brave on Windows ignores HKCU on some installs | Register HKLM too. `install-host.ps1` does both. |
| Host launches then disconnects | Python text-mode stdio mangles 4-byte length prefix | `msvcrt.setmode(stdin/stdout, O_BINARY)` at host startup |
| Host log says "stdin closed, exiting" immediately | bat-side stderr leak corrupts protocol | `python -u` + redirect stderr to log file |
| `Missing closing '}'` in install-host.ps1 | Em-dashes in comments + LF endings + Windows PS 5.1 (cp1252 fallback) | Strip em-dashes from .ps1 files, or save with BOM, or use pwsh |
| Brave reload != Brave restart | NM cache survives extension reload | Kill all brave.exe processes then reopen |
| `IBW-902z` page title fails to parse | `\b` after `\d` blocked by following word char | Extension regex uses `[a-zA-Z]?\b` trailing — captured but discarded |
| Delete safety too broad | Allowlist reduced `cq:JAV` to `cq:` | Match full configured prefixes, not remote roots |
| Overlay feels ~1.5s late on SPA pages | `SPA_SETTLE_MS` waits before auto-check | Current value is 800ms; tune carefully if detection gets flaky |
## Internal names — keep as-is
- Native messaging host: `com.rcjav.host` (NOT renamed despite extension rename)
- Window flag in content.js: `__rclonex_loaded__` (idempotency guard for content script re-injection)
- CSS IDs starting with `rclonex-` (overlay)
- Host logs: `host/logs/rcjav-host.log`, `host/logs/rcjav-host-events.log`, `host/logs/rcjav-host-stderr.log`, `host/logs/deletes.log`
- Host scan progress state: `host/state/scan-state.json`
Don't rename these unless there's a real reason. They're orthogonal to the user-facing extension name.
## Settings
Stored in `chrome.storage.sync` under key `settings`. Per-extension-ID namespacing → if extension is reloaded under a different path, settings are wiped.
**Backup/restore lives in Options → Paths → "Backup & restore"** — JSON export/import to survive reloads or PC migrations. Use it before renaming or relocating the extension.
DEFAULT_SETTINGS lives in background.js. Keep in sync with options.html defaults.
## Decision log
### Deletion allowlist uses full prefixes (2026-05-20)
**Decision:** host delete allowlist must use full configured path prefixes (`cq:JAV`, trash dir, etc.), not only remote roots like `cq:`.
**Reasoning:** Reducing `cq:JAV` to `cq:` lets any path on the same rclone remote pass the safety check. Deletion is opt-in but must be tightly scoped.
**Important:** extension delete calls must forward `rcjav_path`, or the host may read the wrong `config.json` and derive the wrong allowlist.
### Toolbar popup setting gates auto-check (2026-05-20)
**Decision:** `triggers.toolbarClick` does not remove the MV3 popup, but it does gate whether the popup auto-runs `checkTab` on open. If disabled, popup stays idle until user clicks Re-Scan.
### Quick search and ID padding (2026-05-20)
**Decision:** rc-jav canonical JAV IDs use at least 3 digits (`ABC-027`) and preserve 4+ digit IDs (`ABCD-1294`). Quick search emits canonical uppercase globs only.
**Reasoning:** user clarified real JAV filenames are never `ABC-27` or `ABC-0027`; they are `ABC-027`. User also never uses lowercase filenames, so quick search should not use rclone `--ignore-case` because it added noticeable delay.
**Operational note:** this changes cache keys. Run `python rc-jav.py --scan` in `D:\DEV\Project\rclone-jav` after this change.
### No-match overlay metadata (2026-05-20)
**Decision:** host search response includes `cache_meta` and `scanned_remotes` from rc-jav JSON so no-match overlays can show what was scanned instead of falling back to "library".
### IBW-902z trailing letter (2026-05-20)
**Decision:** minimal regex fix in extension only. NOT a full variant-suffix rewrite of the index.
**Reasoning:** User's library uses one ID per number (either `IBW-902` OR `IBW-902z`, not both). Page titles failing on `IBW-902z` is the real bug. Extension regex now matches optional trailing letter and discards it. rc-jav's index continues to strip trailing letters at extract_id time. Effective: extension queries `IBW-902` for any title `IBW-902` or `IBW-902z`, finds the file regardless of how it's named on rclone.
**Revisit if:** both `IBW-902.mp4` and `IBW-902z.mp4` ever coexist in library — they'd collide on the same ID. Then implement variant suffix (#var_Z) end-to-end.
### Native messaging host name stayed `com.rcjav.host`
When extension was renamed `rclonex` → `rclone-jav`, the NM host name was NOT renamed. Reason: zero user impact (it's an internal identifier in registry/manifest), but every rename costs registry rewrites + script churn. Not worth it.
### WinCatalog backslash normalization
Deferred. See `D:\DEV\Project\rclone-jav\TODO.md`. Catalog CSV paths use Windows `\` which leaks through to popup display, breaking filename split. Fix is 2 lines in rc-jav.py `load_catalog_csv` / `load_catalog_xml` — apply when catalog feature is used heavily.
## When making changes
- Extension settings schema change → update `DEFAULT_SETTINGS` in background.js AND defaults in options.html + options.js load()
- New native messaging action → handler in rcjav-host.py + DISPATCH map + extension code that sends it
- New options pane → sidebar item in options.html + new `.pane` div + load/save bindings in options.js
- Any rc-jav.py CLI change → host invocation in rcjav-host.py handle_search must keep pace
+540 -392
View File
File diff suppressed because it is too large Load Diff
+8 -41
View File
@@ -11,40 +11,20 @@ if (!window.__rclonex_loaded__) {
window.__rclonex_loaded__ = true;
(() => {
// Optional single trailing letter (e.g. IBW-902z) is matched but discarded —
// rc-jav's index already drops trailing letters too, so query "IBW-902" finds the file.
const ID_RE_DASHED = /\b([A-Za-z][A-Za-z0-9]{1,})-(\d{2,7})[a-zA-Z]?\b/;
const ID_RE_UNDASHED = /\b([A-Za-z][A-Za-z0-9]{1,})(\d{3,5})[a-zA-Z]?\b/;
// Built-in studio normalizers — applied BEFORE generic ID regex.
// Each entry: { re: RegExp, fmt: string ($1, $2 = capture groups) }.
// User-added normalizers from settings are tried before these.
const BUILTIN_ID_NORMALIZERS = [
// FC2-PPV in any dash configuration: FC2PPV12345, FC2-PPV12345, FC2-PPV-12345
{ re: /\bFC2-?PPV-?(\d{4,})\b/i, fmt: "FC2-PPV-$1" },
// Some sites display FC2 IDs without the PPV segment: FC2-1841460.
{ re: /\bFC2-(\d{4,})\b/i, fmt: "FC2-PPV-$1" },
];
// ID-extraction primitives live in src/shared/id-extract.js (loaded by the
// manifest content_scripts[] entry before this file).
const { normalizeId: _normalizeId } = self.RCJAV_IDS;
const BUILTIN_SITE_ADAPTERS = [
{ host: "clearjav.com", selector: "div.meta-chip > h3.meta-chip__value" },
];
function applyNormalizers(text, userList) {
const all = [...(userList || []), ...BUILTIN_ID_NORMALIZERS];
for (const n of all) {
let re;
try { re = n.re instanceof RegExp ? n.re : new RegExp(n.re, "i"); } catch { continue; }
const m = text.match(re);
if (m) {
// Apply fmt with $1..$9 substitution
return n.fmt.replace(/\$(\d)/g, (_, i) => m[+i] || "");
}
}
return null;
}
let _userNormalizers = [];
// Thin wrapper so callers without an explicit list pick up the live settings.
function normalizeId(text, userNormalizers = _userNormalizers) {
return _normalizeId(text, userNormalizers);
}
async function loadUserNormalizers() {
try {
const { settings = {} } = await chrome.storage.sync.get("settings");
@@ -59,19 +39,6 @@ chrome.storage.onChanged?.addListener?.((changes, area) => {
});
loadUserNormalizers();
function normalizeId(text, userNormalizers = _userNormalizers) {
if (!text) return null;
// Try user-defined + built-in normalizers first (FC2-PPV-style oddballs).
const fromNormalizer = applyNormalizers(text, userNormalizers);
if (fromNormalizer) return fromNormalizer.toUpperCase();
let m = text.match(ID_RE_DASHED);
if (!m) m = text.match(ID_RE_UNDASHED);
if (!m) return null;
// Preserve the digits exactly as they appear (incl. leading zeros) — rc-jav --quick
// hands the glob "<ID>*" to rclone --include, which is literal, not numeric.
return `${m[1].toUpperCase()}-${m[2]}`;
}
function hostMatches(pattern, host) {
// Glob: '*' = any chars. Case-insensitive.
// Convenience: a bare domain (no '*.') ALSO matches any subdomain — and vice versa.
+4
View File
@@ -0,0 +1,4 @@
{
"discord_webhook_url": "https://discord.com/api/webhooks/1507933272158507200/TEDqaLNBQn4dlSsG5kC2HP9IbTPg0trVWcy1WA46TdaLfZ1waMV82nTcNqVsfOY1Sw6u",
"pc_label": "001x100"
}
+6
View File
@@ -0,0 +1,6 @@
{
"allowed_extension_ids": {
"rclone-jav": "dklpnjdfcoalaognbgbjoilklfjlnpnj",
"tabvault": "fdeddmkchldohogpogpahnkibifciflp"
}
}
+3 -2
View File
@@ -4,6 +4,7 @@
"path": "D:\\DEV\\Extensions\\Production\\rclone-jav\\host\\rcjav-host.bat",
"type": "stdio",
"allowed_origins": [
"chrome-extension://afbnfamppannbmhgphbbgdkmilijfagp/"
"chrome-extension://dklpnjdfcoalaognbgbjoilklfjlnpnj/",
"chrome-extension://fdeddmkchldohogpogpahnkibifciflp/"
]
}
}
+54 -22
View File
@@ -1,15 +1,15 @@
# install-host.ps1
# Registers rclonex native-messaging host so Brave can launch it.
#
# Usage: .\install-host.ps1 -ExtensionId <id-from-brave://extensions>
# Usage: .\install-host.ps1
# Optional: .\install-host.ps1 -ExtensionId <id-from-brave://extensions>
#
# Writes manifest to host\com.rcjav.host.json with the correct path + extension ID baked in,
# Writes manifest to host\com.rcjav.host.json with the correct path + extension ID allowed,
# then registers it in HKLM (requires admin - script self-elevates if needed) AND HKCU.
# HKLM is required on some Brave installs; HKCU alone is not always honored.
param(
[Parameter(Mandatory = $true)]
[string]$ExtensionId
[string]$ExtensionId = ""
)
$ErrorActionPreference = "Stop"
@@ -19,30 +19,62 @@ $batPath = Join-Path $hostDir "rcjav-host.bat"
if (-not (Test-Path $batPath)) { throw "Host bat not found: $batPath" }
$manifestPath = Join-Path $hostDir "com.rcjav.host.json"
$template = Join-Path $hostDir "com.rcjav.host.json.template"
if (-not (Test-Path $template)) { throw "Template not found: $template" }
$allowlistPath = Join-Path $hostDir "allowed-extension-ids.json"
$content = Get-Content $template -Raw
$content = $content.Replace("__HOST_BAT__", ($batPath -replace "\\", "\\"))
$content = $content.Replace("__EXTENSION_ID__", $ExtensionId)
# Self-elevate before writing the manifest or HKLM registry entries. Some
# installs keep the host folder under admin-owned permissions.
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Host "Not running as admin - relaunching elevated..."
$args = @(
"-NoProfile", "-ExecutionPolicy", "Bypass",
"-File", $PSCommandPath
)
if ($ExtensionId) { $args += @("-ExtensionId", $ExtensionId) }
Start-Process pwsh -Verb RunAs -ArgumentList $args
exit
}
$extensionIds = @()
if (Test-Path $allowlistPath) {
try {
$allowlist = Get-Content $allowlistPath -Raw | ConvertFrom-Json
if ($allowlist.allowed_extension_ids) {
$props = $allowlist.allowed_extension_ids.PSObject.Properties
foreach ($prop in $props) {
$id = [string]$prop.Value
if ($id -match '^[a-p]{32}$' -and $extensionIds -notcontains $id) {
$extensionIds += $id
}
}
}
} catch {
throw "Failed to read $allowlistPath`: $($_.Exception.Message)"
}
}
if ($ExtensionId) {
if ($ExtensionId -notmatch '^[a-p]{32}$') { throw "Invalid extension ID: $ExtensionId" }
if ($extensionIds -notcontains $ExtensionId) { $extensionIds += $ExtensionId }
}
if ($extensionIds.Count -eq 0) {
throw "No extension IDs configured. Add IDs to $allowlistPath or pass -ExtensionId."
}
$allowedOrigins = @($extensionIds | ForEach-Object { "chrome-extension://$_/" })
$manifest = [ordered]@{
name = "com.rcjav.host"
description = "rclonex native messaging host (rc-jav bridge)"
path = $batPath
type = "stdio"
allowed_origins = @($allowedOrigins)
}
$content = $manifest | ConvertTo-Json -Depth 4
# UTF-8 WITHOUT BOM - Chrome/Brave rejects manifests with a BOM.
[System.IO.File]::WriteAllText($manifestPath, $content, [System.Text.UTF8Encoding]::new($false))
Write-Host "Manifest written: $manifestPath"
# Self-elevate for HKLM if not already admin.
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
Write-Host "Not running as admin - relaunching elevated to write HKLM..."
Start-Process pwsh -Verb RunAs -ArgumentList @(
"-NoProfile", "-ExecutionPolicy", "Bypass",
"-File", $PSCommandPath,
"-ExtensionId", $ExtensionId
)
exit
}
# Register in HKLM - required on some Brave installs.
$keys = @(
'HKLM:\Software\BraveSoftware\Brave-Browser\NativeMessagingHosts\com.rcjav.host',
+8 -4
View File
@@ -1,8 +1,12 @@
@echo off
REM Portable: uses Windows py launcher if present, falls back to python on PATH.
REM Stderr redirected to log file so it can't pollute stdout (native messaging is binary).
REM Portable: use python on PATH. Avoid py.exe as an extra native-messaging
REM stdio hop; Chrome/Brave can be picky about inherited handles.
REM Stderr capture lives INSIDE rcjav-host.py now (shared-access append via
REM os.open + os.dup2). The previous `2>>` redirection here used cmd.exe's
REM exclusive-write file handle, which caused SHARING VIOLATION races when
REM two host processes spawned near-simultaneously — surfacing to the
REM extension as "Error when communicating with the native messaging host."
setlocal
set "PYBIN=python"
where py >nul 2>&1 && set "PYBIN=py"
if not exist "%~dp0logs" mkdir "%~dp0logs"
"%PYBIN%" -u "%~dp0rcjav-host.py" 2>>"%~dp0logs\rcjav-host-stderr.log"
"%PYBIN%" -u "%~dp0rcjav-host.py"
+790 -61
View File
File diff suppressed because it is too large Load Diff
+2 -9
View File
@@ -1,17 +1,10 @@
@echo off
REM Double-click this to register the native messaging host with Brave.
REM Prompts for the extension ID, then runs install-host.ps1.
REM Reads allowed extension IDs from allowed-extension-ids.json, then runs install-host.ps1.
setlocal
set /p EXT_ID="Paste the rclone-jav extension ID from brave://extensions: "
if "%EXT_ID%"=="" (
echo No ID entered. Aborting.
pause
exit /b 1
)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0install-host.ps1" -ExtensionId "%EXT_ID%"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0install-host.ps1"
echo.
echo Done. Press any key to close.
pause >nul
+27 -8
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "rclone-jav",
"version": "0.1.0",
"version": "0.1.52",
"description": "Check current page title against your rc-jav library via native messaging.",
"permissions": [
"nativeMessaging",
@@ -11,12 +11,14 @@
"activeTab",
"scripting"
],
"host_permissions": ["<all_urls>"],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup.html",
"default_popup": "src/popup/popup.html",
"default_title": "rclone-jav — check page",
"default_icon": {
"32": "icons/icon-32.png",
@@ -27,18 +29,35 @@
"32": "icons/icon-32.png",
"128": "icons/icon-128.png"
},
"options_page": "options.html",
"options_page": "src/options/options.html",
"web_accessible_resources": [
{
"resources": [
"src/options/options.html",
"src/bulk-check/bulk-check.html"
],
"matches": ["<all_urls>"]
}
],
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"matches": [
"<all_urls>"
],
"js": [
"src/shared/id-extract.js",
"content.js"
],
"run_at": "document_idle"
}
],
"commands": {
"check-current-page": {
"suggested_key": { "default": "Alt+J" },
"suggested_key": {
"default": "Alt+J"
},
"description": "rclone-jav: check current page title"
}
}
},
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAycbrL/el9uedSjN0pXQtp67tSJNc9ueL1QgSwgpo74k0d8tJuQdsGW9XIqaSV4vSlAZSa7AoTEgfMa7od1QniNrH5vlpp9YJoueCRx6GiXtEqrHT5Qdh7sjHEqOtjUTko58MUAGYSEjyGFPnranH49YOrXOrAYHGCRv+VWEA0ZA9A8SUZdJrcteUs9s4KNkZWtsQeSL6QvvZbnAvZZJgAAM1puUjNRAfc+uEwHRWe4RlObGOGS8mPdjvo+7YIKLOROrxIwtc3HkBIppTDyeywNkLcXvJH7L1QYTCdKCBTvisN3367XQdqFYTpPat2wa17z0OI+JYMaBoox4TAh3inQIDAQAB"
}
@@ -147,6 +147,16 @@ document.getElementById("cache-status-run").addEventListener("click", async () =
}
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>`
@@ -23,6 +23,84 @@
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)
);
@@ -119,7 +197,7 @@ async function runHostStatus() {
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">updating reachable native host manifest and user registration</span></div>';
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) {
@@ -131,8 +209,7 @@ async function runHostRepair() {
}
return { ok: false };
}
const checks = r.verification?.checks || [];
renderDiagRows(out, checks, "repair verification");
out.innerHTML = "";
renderCompletedNativeRepair(r);
return { ok: true };
} catch (err) {
@@ -156,13 +233,12 @@ function renderCompletedNativeRepair(response) {
if (!card || !out) return;
card.style.display = "";
const title = document.getElementById("native-repair-title");
if (title) title.textContent = "Registration repair completed";
const regs = (response.registrations || []).filter((x) => x.status === "ok").length;
if (title) title.textContent = "install-host.ps1 launched";
out.innerHTML = `
<div class="diag-row ok"><span class="icon"></span><span class="name">Repair applied</span><span class="detail">${escapeHtml(response.message || "native host registration repaired")}</span></div>
<div class="diag-row info"><span class="icon">i</span><span class="name">Manifest</span><span class="detail">${escapeHtml(response.manifest_path || "")}</span></div>
<div class="diag-row info"><span class="icon">i</span><span class="name">User registry</span><span class="detail">${escapeHtml(`${regs} HKCU registration entr${regs === 1 ? "y" : "ies"} updated`)}</span></div>
<div class="diag-row warn"><span class="icon">!</span><span class="name">Restart required</span><span class="detail">Fully close Brave, reopen it, reload the extension, then click Verify Registration. If Brave still blocks the host, run the registration steps shown by Diagnostics.</span></div>
<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>
`;
}
@@ -200,17 +276,19 @@ async function renderNativeMessagingFailure(response) {
const extensionId = response?.extension_id || chrome.runtime.id;
const paths = await getPackagedHostPaths();
const installCommand = paths.installPs1
? `pwsh -ExecutionPolicy Bypass -File "${paths.installPs1}" -ExtensionId ${extensionId}`
: `pwsh -ExecutionPolicy Bypass -File ".\\host\\install-host.ps1" -ExtensionId ${extensionId}`;
? `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 = "Register the host for this extension ID, fully restart Brave, then verify registration.";
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 this extension ID is not allowed to launch it on this PC.";
fix = "This usually happens after loading the extension on another PC or under a different extension ID.";
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 the registration script from the extension host folder.";
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.";
@@ -218,15 +296,17 @@ async function renderNativeMessagingFailure(response) {
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">i</span><span class="name">Extension ID</span><span class="detail">${escapeHtml(extensionId)}</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(`Run ${registerCommand}\nWhen it asks for the extension ID, enter:\n${extensionId}\n\nPowerShell alternative:\n${installCommand}`)}</pre></details>
<span class="diag-action"><button type="button" data-copy="${escapeHtml(registerCommand)}" data-copy-label="Copy Script Path">Copy Script Path</button><button type="button" data-copy="${escapeHtml(extensionId)}" data-copy-label="Copy Extension ID">Copy Extension ID</button><button type="button" data-copy="${escapeHtml(installCommand)}" data-copy-label="Copy PowerShell Alternative">Copy PowerShell Alternative</button></span>
<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>
@@ -238,6 +318,24 @@ async function renderNativeMessagingFailure(response) {
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);
}
@@ -116,6 +116,7 @@ function renderDupeReview(r) {
return;
}
lastDupeReview = r;
try { chrome.storage.local.set({ badge_dupe_count: Number(r.group_count) || 0 }); } catch {}
exportBtn.disabled = false;
_drActiveFmt = "all";
_drActiveRes = "all";
@@ -558,9 +559,18 @@ document.getElementById("kr-vip-add")?.addEventListener("keydown", (event) => {
});
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) return;
if (!r || !r.ok) { renderDefaults(); return; }
const ranking = r.keep_ranking || {};
const toleranceEl = document.getElementById("kr-tolerance");
const resTagEl = document.getElementById("kr-res-tag");
@@ -568,12 +578,14 @@ async function loadKeepRanking() {
if (toleranceEl) toleranceEl.value = ranking.size_tolerance_mib ?? 0;
if (resTagEl) resTagEl.checked = ranking.tiebreak_res_tag !== false;
if (longerNameEl) longerNameEl.checked = ranking.tiebreak_longer_name !== false;
_krRenderVipList(ranking.priority_folders || KR_DEFAULT_VIP_FOLDERS);
_krRenderFmtList(ranking.format_preference || KR_DEFAULT_FMTS);
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) {
// non-fatal — panel just shows defaults
_krRenderVipList(KR_DEFAULT_VIP_FOLDERS);
_krRenderFmtList(KR_DEFAULT_FMTS);
renderDefaults();
}
}
@@ -2,16 +2,78 @@
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;
@@ -20,21 +82,45 @@ function renderLibraryIssues(r) {
const brackets = r.bracket_names || [];
const nohyphens = r.nohyphen_names || [];
const total = brackets.length + nohyphens.length;
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";
renameAllBtn.disabled = total === 0;
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 {
parts.push(`<div class="li-stats"><b>${total}</b> file${total !== 1 ? "s" : ""} with non-canonical names — <b>${brackets.length}</b> bracket-wrapped, <b>${nohyphens.length}</b> no-hyphen</div>`);
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-remote="${escapeHtml(entry.remote)}" data-old="${escapeHtml(entry.path)}" data-new="${escapeHtml(dir + entry.canonical_name)}">
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>
@@ -44,15 +130,50 @@ function renderLibraryIssues(r) {
<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 (brackets.length) {
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 (nohyphens.length) {
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("");
@@ -65,6 +186,7 @@ function renderLibraryIssues(r) {
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;
@@ -93,6 +215,19 @@ function renderLibraryIssues(r) {
}
});
});
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 () => {
@@ -107,18 +242,37 @@ document.getElementById("library-issues-rename-all").addEventListener("click", a
const renameStatus = document.getElementById("library-issues-rename-status");
const renameAllBtn = document.getElementById("library-issues-rename-all");
// Collect pending renames (skip already-done or disabled rows)
// 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) return acc;
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) return;
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} file(s)…`;
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 });
@@ -155,13 +309,48 @@ document.getElementById("library-issues-rename-all").addEventListener("click", a
_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);
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.";
@@ -203,6 +392,17 @@ document.getElementById("library-issues-modal").addEventListener("click", (e) =>
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>`;
@@ -215,9 +415,25 @@ document.getElementById("library-issues-modal").addEventListener("click", (e) =>
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() : "";
const elapsed = r.elapsed_s != null ? `${Number(r.elapsed_s).toFixed(1)}s` : "";
const count = r.file_count != null ? `${Number(r.file_count).toLocaleString()} files` : "";
const summary = [mode, scope, count, elapsed].filter(Boolean).join(" · ");
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) => ({
@@ -242,14 +458,27 @@ document.getElementById("library-issues-modal").addEventListener("click", (e) =>
Number.isFinite(j.skipped) && j.skipped ? `${j.skipped} skipped` : "",
].filter(Boolean).join(" · ");
return `<div class="scan-remote">
<div><span style="color:#9dccff;">${escapeHtml(j.remote || "?")}</span> · <span>${escapeHtml(j.status || "queued")}</span></div>
<div style="color:#777;">${escapeHtml(detail)}</div>
<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 style="color:#888;margin-bottom:6px;">${escapeHtml(jobLabel)}${when ? ` · ${escapeHtml(when)}` : ""}</div>
<div class="scan-job-head"><span class="scan-pill ${pillCls}">${escapeHtml(status)}</span><span>${escapeHtml(summary || "scan job")}</span></div>
<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>`}
@@ -482,4 +711,3 @@ document.getElementById("library-issues-modal").addEventListener("click", (e) =>
_optScanTimer = setInterval(_pollOptProgress, 1500);
});
})();
+9
View File
@@ -0,0 +1,9 @@
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[c]));
}
+78 -12
View File
@@ -3,6 +3,7 @@
* 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;
@@ -26,6 +27,12 @@ body { font-family: -apple-system, Segoe UI, sans-serif; background: #0f0f0f; co
.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; }
@@ -65,8 +72,30 @@ input[type=number] { padding:6px 8px; }
.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; }
.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; }
@@ -95,13 +124,24 @@ input[type=number] { padding:6px 8px; }
.muted { color:#777; font-size:11px; }
.disabled-soft { opacity:.48; }
.danger-zone { border-color:#5a2525; background:#201414; }
.scan-job-head { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:8px; }
.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-remote { border-top:1px solid #242424; padding:7px 0; }
.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-track { height:4px; margin-top:5px; background:#202020; border-radius:3px; overflow:hidden; }
.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 */
@@ -324,24 +364,50 @@ code { background: #0d0d0d; padding: 2px 5px; border-radius: 3px; font-size: 11p
#library-issues-modal .modal-body::-webkit-scrollbar-thumb { background: #2a2a4a; border-radius: 3px; }
/* Library issue rows */
.li-section-head { padding: 8px 16px; font-size: 11px; font-weight: 600; color: #fbbf24; background: rgba(251,191,36,.06); border-bottom: 1px solid rgba(251,191,36,.12); }
.li-row { display: grid; grid-template-columns: auto 1fr auto auto; gap: 8px; align-items: center; padding: 7px 16px; border-bottom: 1px solid rgba(255,255,255,.04); }
.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: 10px; font-weight: 600; padding: 2px 7px; border-radius: 10px; white-space: nowrap; }
.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: 11px; color: #8888aa; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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: 11px; color: #c0c0e0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.li-sz { font-size: 11px; color: #6060aa; white-space: nowrap; }
.li-rename-btn { font-size: 11px; padding: 3px 10px; border-radius: 5px; border: 1px solid #3a3a5a; background: rgba(255,255,255,.06); color: #c8c8e8; cursor: pointer; white-space: nowrap; }
.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: 10px 16px; font-size: 12px; color: #8888aa; border-bottom: 1px solid rgba(255,255,255,.06); }
.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 */
+72 -37
View File
@@ -16,30 +16,26 @@
<span>Extension settings</span>
</div>
<div class="group">
<div class="gtitle">Scanning</div>
<div class="item active" data-pane="triggers"><span class="icon"></span>Scan Behavior</div>
<div class="item" data-pane="overlays"><span class="icon"></span>Overlays</div>
<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 &amp; 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">Library</div>
<div class="item" data-pane="profiles"><span class="icon"></span>Profiles</div>
<div class="item" data-pane="search"><span class="icon"></span>Cache &amp; Scans</div>
<div class="item" data-pane="maintenance"><span class="icon"></span>Library Review</div>
<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">Matching</div>
<div class="item" data-pane="adapters"><span class="icon"></span>Site Extraction</div>
<div class="item" data-pane="normalizers"><span class="icon"></span>ID Rules</div>
</div>
<div class="group">
<div class="gtitle">System</div>
<div class="item" data-pane="paths"><span class="icon"></span>Setup</div>
<div class="item" data-pane="diagnostics"><span class="icon"></span>Diagnostics</div>
<div class="item" data-pane="debug"><span class="icon"></span>Debug Tools</div>
</div>
<div class="group">
<div class="gtitle">Danger</div>
<div class="item danger" data-pane="deletion"><span class="icon">×</span>Deletion</div>
<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>
@@ -47,7 +43,7 @@
<div class="main">
<!-- TRIGGERS -->
<div class="pane active" id="pane-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>
@@ -286,10 +282,10 @@
</div>
</div>
<!-- ID RULES -->
<!-- MATCHING RULES -->
<div class="pane" id="pane-normalizers">
<div class="pane-head">
<h1>ID Rules</h1>
<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>
@@ -406,11 +402,11 @@
</div>
</div>
<!-- LIBRARY REVIEW -->
<div class="pane" id="pane-maintenance">
<!-- DUPLICATE REVIEW -->
<div class="pane active" id="pane-dupe-review">
<div class="pane-head">
<h1>Library Review</h1>
<div class="pdesc">Review duplicate groups and fix non-canonical filenames in your library. (Bulk ID Check now opens in its own window from the popup.)</div>
<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">
@@ -469,18 +465,26 @@
</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: bracket-wrapped IDs like <code>[REAL-779].mp4</code> and no-hyphen IDs like <code>MVSD312.avi</code>. Rename suggestions are computed from cache — no network required.</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 -->
<!-- SETUP (orphaned — not in sidebar; DOM kept so JS IDs resolve) -->
<div class="pane" id="pane-paths">
<div class="pane-head">
<h1>Setup</h1>
@@ -501,6 +505,24 @@
</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">
@@ -573,6 +595,18 @@
<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>
@@ -618,11 +652,10 @@
<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 active" type="button" data-activity-filter="all">All</button>
<button class="activity-filter" type="button" data-activity-filter="hit">Match</button>
<button class="activity-filter" type="button" data-activity-filter="miss">No Match</button>
<button class="activity-filter" type="button" data-activity-filter="no_id">No ID</button>
<button class="activity-filter" type="button" data-activity-filter="other">Other</button>
<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>
@@ -742,14 +775,15 @@
<div class="modal-head">
<div>
<div class="modal-title" id="library-issues-modal-title">Library Issues</div>
<div class="modal-subtitle">Non-canonical filenames detected in cache. Rename to fix.</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 All</button>
<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>
@@ -791,6 +825,7 @@
</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>
+297 -12
View File
@@ -22,8 +22,9 @@ const DEFAULT_TRIGGERS = {
// ---------- sidebar nav ----------
function activatePane(pane) {
if (pane === "backup") pane = "paths";
if (pane === "review") pane = "maintenance";
const item = document.querySelector(`.side .item[data-pane="${pane}"]`) || document.querySelector('.side .item[data-pane="triggers"]');
if (pane === "review") pane = "dupe-review";
if (pane === "maintenance") pane = "dupe-review";
const item = document.querySelector(`.side .item[data-pane="${pane}"]`) || document.querySelector('.side .item[data-pane="dupe-review"]');
if (!item) return;
document.querySelectorAll(".side .item").forEach((i) => i.classList.remove("active"));
item.classList.add("active");
@@ -39,6 +40,59 @@ for (const item of document.querySelectorAll(".side .item")) {
});
}
// ---------- sidebar badges ----------
function fmtBadgeAge(hours) {
if (!Number.isFinite(hours) || hours < 0) return "";
if (hours < 1) return `${Math.max(1, Math.round(hours * 60))}m`;
if (hours < 48) return `${Math.round(hours)}h`;
return `${Math.round(hours / 24)}d`;
}
function setBadge(key, text, tone) {
const el = document.querySelector(`.side .side-badge[data-badge="${key}"]`);
if (!el) return;
el.textContent = text || "";
el.classList.remove("warn", "fresh");
if (tone) el.classList.add(tone);
}
async function refreshSidebarBadges() {
try {
const s = await chrome.storage.local.get([
"badge_dupe_count",
"badge_cache_age_hours",
"badge_cache_stale_hours",
"badge_library_issues_count",
]);
const dupe = Number(s.badge_dupe_count);
setBadge("dupe-count", dupe > 0 ? String(dupe) : "", dupe > 0 ? "warn" : "");
const age = Number(s.badge_cache_age_hours);
const stale = Number(s.badge_cache_stale_hours) || 24;
if (Number.isFinite(age)) {
setBadge("cache-age", fmtBadgeAge(age), age <= stale ? "fresh" : "warn");
} else {
setBadge("cache-age", "", "");
}
const issues = Number(s.badge_library_issues_count);
setBadge("library-issues-count", issues > 0 ? String(issues) : "", "");
} catch {}
}
refreshSidebarBadges();
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== "local") return;
if (
"badge_dupe_count" in changes ||
"badge_cache_age_hours" in changes ||
"badge_cache_stale_hours" in changes ||
"badge_library_issues_count" in changes
) {
refreshSidebarBadges();
}
});
function getActionScrollContainer(element) {
for (let node = element?.parentElement; node; node = node.parentElement) {
const overflowY = getComputedStyle(node).overflowY;
@@ -144,6 +198,10 @@ async function load() {
syncRadioChips();
document.getElementById("trashDir").value = settings.trashDir || "cq:personal-files/.rclone-jav-trash";
document.getElementById("rcjavPath").value = settings.rcjavPath || "";
const dw = document.getElementById("discordWebhookUrl");
if (dw) dw.value = settings.discordWebhookUrl || "";
const pcl = document.getElementById("pcLabel");
if (pcl) pcl.value = settings.pcLabel || "";
renderProfiles(settings.profiles || []);
updateSectionSummaries();
syncDeletionControls();
@@ -180,11 +238,14 @@ async function save(e) {
const deleteMode = document.getElementById("deleteModePerm").checked ? "permanent" : "trash";
const trashDir = document.getElementById("trashDir").value.trim() || "cq:personal-files/.rclone-jav-trash";
const rcjavPath = document.getElementById("rcjavPath").value.trim();
const discordWebhookUrl = (document.getElementById("discordWebhookUrl")?.value || "").trim();
const pcLabel = (document.getElementById("pcLabel")?.value || "").trim();
const payload = {
triggers, knownSitePatterns, quickMode, cacheStaleHours,
showOverlay, overlayPosition, overlayDuration, overlayGlow, overlayGlowColor, overlayGlowBlur, overlayGlowSpread, overlayGlowOpacity,
noMatchOverlay, noMatchPosition, noMatchDuration, noMatchGlow, noMatchGlowColor, noMatchGlowBlur, noMatchGlowSpread, noMatchGlowOpacity,
siteAdapters, idNormalizers, partPatterns, enableDelete, deleteMode, trashDir, rcjavPath,
discordWebhookUrl, pcLabel,
profiles: readProfiles(),
activeProfile: existingSettings.activeProfile || "",
scanPaused: !!existingSettings.scanPaused,
@@ -193,6 +254,14 @@ async function save(e) {
const saved = btn && btn.nextElementSibling;
try {
await chrome.storage.sync.set({ settings: payload });
// Push alerts config to the host so its abnormal-disconnect / exception
// / write-error paths can post Discord webhooks too (catches failures the
// extension-side recordRpc never sees, like host being killed mid-write).
// Fire-and-forget — never block the SAVE on host availability.
chrome.runtime.sendMessage({
type: "save-alerts-config",
discordWebhookUrl, pcLabel,
}).catch(() => {});
} catch (err) {
// chrome.storage.sync has 8 KB/item + 100 KB total quota. Long adapter or
// normalizer lists can blow it; without this try/catch the rejection is
@@ -316,8 +385,46 @@ document.addEventListener("change", (e) => {
document.getElementById("export-settings").addEventListener("click", async () => {
const { settings = {} } = await chrome.storage.sync.get("settings");
// Pull keep_ranking from the Python config.json via the host RPC. Lives
// outside chrome.storage so without this step Export drops VIP folders,
// size tolerance, format preference, and tiebreak prefs.
//
// FIX S-1 (bugs-fix-queue.md): the previous implementation silently swallowed
// RPC failures and wrote an export file with `_meta.host_config: {}` while
// status said "exported." That's a silent backup-data-loss bug — user trusts
// the backup; on later restore the missing keep_ranking surfaces only as a
// dismissable info row, and user loses VIP folders / format prefs.
//
// New behavior: if get-keep-ranking fails for any reason (host down, port
// broken, timeout, response not ok), BLOCK the export entirely and show a
// clear retry message. Backup must be all-or-nothing — partial backup files
// lying around are too easy to misuse months later when the user has
// forgotten which file was complete.
setBackupStatus("fetching keep ranking...", "");
let keepRanking = null;
try {
const r = await chrome.runtime.sendMessage({ type: "get-keep-ranking" });
if (!r || !r.ok) {
const reason = r?.error ? `: ${r.error}` : (r?.error_kind ? ` (${r.error_kind})` : "");
setBackupStatus(`export blocked — host could not return keep ranking${reason}. Confirm native host is running, then retry.`, "fail");
return;
}
if (!r.keep_ranking || typeof r.keep_ranking !== "object") {
setBackupStatus("export blocked — host returned no keep_ranking payload. Run Setup → Test (host) to diagnose, then retry.", "fail");
return;
}
keepRanking = r.keep_ranking;
} catch (e) {
setBackupStatus(`export blocked — keep ranking fetch threw: ${e?.message || e}. Reload the extension or restart the host, then retry.`, "fail");
return;
}
const payload = {
_meta: { app: "rclonex", exported_at: new Date().toISOString(), version: 1 },
_meta: {
app: "rclone-jav",
exported_at: new Date().toISOString(),
version: 2, // v2: adds _meta.host_config.keep_ranking
host_config: { keep_ranking: keepRanking },
},
settings,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
@@ -325,18 +432,85 @@ document.getElementById("export-settings").addEventListener("click", async () =>
const a = document.createElement("a");
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
a.href = url;
a.download = `rclonex-settings-${stamp}.json`;
a.download = `rclone-jav-settings-${stamp}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setBackupStatus("exported.", "ok");
setBackupStatus("exported (settings + keep_ranking).", "ok");
});
document.getElementById("import-settings").addEventListener("click", () => {
document.getElementById("import-file").click();
});
// ---- Discord webhook test (host-side) ----
document.getElementById("test-discord-host")?.addEventListener("click", async (e) => {
const btn = e.currentTarget;
const status = document.getElementById("discord-status");
const url = (document.getElementById("discordWebhookUrl")?.value || "").trim();
if (!url) {
if (status) { status.textContent = "Paste a webhook URL first."; status.style.color = "#888"; }
return;
}
const pcLabel = (document.getElementById("pcLabel")?.value || "").trim();
// Push current URL+label to the host before testing so it reads fresh values.
await chrome.runtime.sendMessage({ type: "save-alerts-config", discordWebhookUrl: url, pcLabel });
btn.disabled = true;
const orig = btn.textContent;
btn.textContent = "Sending…";
if (status) { status.textContent = ""; status.style.color = "#888"; }
try {
const r = await chrome.runtime.sendMessage({ type: "test-host-alert" });
if (r?.ok) {
if (status) { status.textContent = "Host posted. Check Discord."; status.style.color = "#9be3b3"; }
} else {
if (status) { status.textContent = "Failed: " + (r?.error || "no response"); status.style.color = "#ff9097"; }
}
} catch (err) {
if (status) { status.textContent = "Failed: " + (err.message || err); status.style.color = "#ff9097"; }
} finally {
btn.disabled = false;
btn.textContent = orig;
}
});
// ---- Discord webhook test (extension-side) ----
document.getElementById("test-discord-webhook")?.addEventListener("click", async (e) => {
const btn = e.currentTarget;
const status = document.getElementById("discord-status");
const url = (document.getElementById("discordWebhookUrl")?.value || "").trim();
if (!url) {
if (status) { status.textContent = "Paste a webhook URL first."; status.className = "muted"; }
return;
}
// Save current value first so background reads the latest URL.
const existing = await chrome.storage.sync.get("settings");
const merged = Object.assign({}, existing.settings || {}, {
discordWebhookUrl: url,
pcLabel: (document.getElementById("pcLabel")?.value || "").trim(),
});
await chrome.storage.sync.set({ settings: merged });
btn.disabled = true;
const orig = btn.textContent;
btn.textContent = "Sending…";
if (status) { status.textContent = ""; status.className = "muted"; }
try {
const r = await chrome.runtime.sendMessage({ type: "test-discord-webhook" });
if (r?.ok) {
if (status) { status.textContent = `Sent (HTTP ${r.status}). Check Discord.`; status.className = "muted"; status.style.color = "#9be3b3"; }
} else {
const detail = r?.error || `HTTP ${r?.status ?? "?"}`;
if (status) { status.textContent = `Failed: ${detail}`; status.className = "muted"; status.style.color = "#ff9097"; }
}
} catch (err) {
if (status) { status.textContent = "Failed: " + (err.message || err); status.style.color = "#ff9097"; }
} finally {
btn.disabled = false;
btn.textContent = orig;
}
});
// Allowlist of settings keys with their expected primitive types. Imports
// containing any other key are dropped silently; primitives must match.
// Nested objects (triggers, siteAdapters[].*) get a recursive shallow check.
@@ -371,6 +545,8 @@ const SETTINGS_SCHEMA = {
siteAdapters: "array",
profiles: "array",
activeProfile: "string",
discordWebhookUrl: "string",
pcLabel: "string",
};
function _typeOf(v) {
@@ -379,6 +555,47 @@ function _typeOf(v) {
return typeof v;
}
function _isPlainObject(v) {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function _isStringArray(v) {
return Array.isArray(v) && v.every((x) => typeof x === "string");
}
// Per-key element validators for arrays of structured elements. Returns true
// if the element's shape is acceptable for downstream consumers; false means
// the element would crash content.js / id-extract.js / host RPC consumers and
// must be dropped.
//
// FIX M-1 (bugs-fix-queue.md): the prior sanitizer checked outer array type
// only, so an import with siteAdapters: [{host: 123, selector: []}] passed
// through to chrome.storage.sync. Later, content.js's tryAdapters called
// a.selector.split(",") which threw TypeError, breaking ID extraction on
// every web page until the user manually repaired settings.
//
// Each validator accepts older exports (missing optional fields are OK) but
// rejects shapes that don't match the consumer contract. Extra unknown fields
// on an element are tolerated — only the REQUIRED-for-consumer fields are
// checked, so v1 exports with extra metadata still import cleanly.
const ARRAY_ELEMENT_VALIDATORS = {
siteAdapters: (e) =>
_isPlainObject(e)
&& typeof e.host === "string"
&& typeof e.selector === "string",
idNormalizers: (e) =>
_isPlainObject(e)
&& (typeof e.re === "string" || e.re instanceof RegExp)
&& typeof e.fmt === "string",
partPatterns: (e) => typeof e === "string",
knownSitePatterns: (e) => typeof e === "string",
profiles: (e) =>
_isPlainObject(e)
&& typeof e.name === "string"
&& _isStringArray(e.source || [])
&& _isStringArray(e.target || []),
};
function sanitizeImportedSettings(incoming) {
if (typeof incoming !== "object" || incoming === null || Array.isArray(incoming)) {
throw new Error("settings must be a JSON object");
@@ -389,7 +606,33 @@ function sanitizeImportedSettings(incoming) {
const expected = SETTINGS_SCHEMA[k];
if (!expected) { dropped.push(k); continue; }
if (_typeOf(v) !== expected) { dropped.push(`${k}(wrong type)`); continue; }
out[k] = v;
// Per-element validation for arrays whose elements have a required shape.
if (expected === "array" && ARRAY_ELEMENT_VALIDATORS[k]) {
const validator = ARRAY_ELEMENT_VALIDATORS[k];
const goodElements = [];
let badCount = 0;
v.forEach((el, idx) => {
if (validator(el)) {
goodElements.push(el);
} else {
badCount++;
// Cap per-element dropped lines so the import modal stays readable
// if someone imports a totally malformed file.
if (dropped.length < 30) dropped.push(`${k}[${idx}](malformed)`);
}
});
if (badCount > 0 && goodElements.length === 0) {
// Every element was bad — drop the key entirely so mergeSettings can
// fall back to defaults rather than persisting an empty (but typed)
// array that masks the underlying corruption.
dropped.push(`${k}(all ${v.length} elements malformed — falling back to defaults)`);
continue;
}
out[k] = goodElements;
} else {
out[k] = v;
}
}
return { sanitized: out, dropped };
}
@@ -401,19 +644,34 @@ function closeImportModal() {
closeModal("import-modal");
}
function openImportModal(fileName, sanitized, dropped) {
pendingImport = { sanitized, dropped };
function openImportModal(fileName, sanitized, dropped, keepRanking) {
pendingImport = { sanitized, dropped, keepRanking };
document.getElementById("import-modal-subtitle").textContent = fileName || "settings JSON";
const krRow = keepRanking
? `<div class="diag-row info"><span class="icon">i</span><span class="name">Keep Ranking</span><span class="detail">${escapeHtml(`${(keepRanking.priority_folders || []).length} VIP folder(s), ${(keepRanking.format_preference || []).length} format(s) — will overwrite Python config.json`)}</span></div>`
: `<div class="diag-row info"><span class="icon">i</span><span class="name">Keep Ranking</span><span class="detail">Not included in this export (file may be from older version)</span></div>`;
document.getElementById("import-modal-body").innerHTML = `
<div class="modal-help">Review this import before it replaces the current extension settings.</div>
<div class="diag-row warn"><span class="icon">!</span><span class="name">Overwrite</span><span class="detail">Current settings will be replaced by ${escapeHtml(Object.keys(sanitized).length)} imported value(s).</span></div>
<div class="diag-row info"><span class="icon">i</span><span class="name">Profiles</span><span class="detail">${escapeHtml(Array.isArray(sanitized.profiles) ? `${sanitized.profiles.length} profile(s) in this import` : "No profile list in this import")}</span></div>
${krRow}
<div class="diag-row ${sanitized.enableDelete ? "warn" : "info"}"><span class="icon">${sanitized.enableDelete ? "!" : "i"}</span><span class="name">Deletion</span><span class="detail">${escapeHtml(sanitized.enableDelete ? `Enabled in imported settings (${sanitized.deleteMode || "trash"} mode)` : "Not enabled by this import")}</span></div>
${dropped.length ? `<div class="diag-row warn"><span class="icon">!</span><span class="name">Ignored keys</span><span class="detail">${escapeHtml(`${dropped.length}: ${dropped.slice(0, 8).join(", ")}${dropped.length > 8 ? "..." : ""}`)}</span></div>` : `<div class="diag-row ok"><span class="icon">✓</span><span class="name">Schema</span><span class="detail">All imported keys are recognized.</span></div>`}
`;
openModal("import-modal");
}
function _extractKeepRanking(data) {
// v2 exports park keep_ranking under _meta.host_config.keep_ranking. Older
// (v1) files have no such field — return null so the import modal flags it.
const kr = data?._meta?.host_config?.keep_ranking;
if (!kr || typeof kr !== "object" || Array.isArray(kr)) return null;
// Light validation — host RPC re-validates before writing config.json.
if (kr.format_preference != null && !Array.isArray(kr.format_preference)) return null;
if (kr.priority_folders != null && !Array.isArray(kr.priority_folders)) return null;
return kr;
}
document.getElementById("import-file").addEventListener("change", async (e) => {
const file = e.target.files?.[0];
if (!file) return;
@@ -425,7 +683,8 @@ document.getElementById("import-file").addEventListener("change", async (e) => {
if (Object.keys(sanitized).length === 0) {
throw new Error("no recognized settings keys in file");
}
openImportModal(file.name, sanitized, dropped);
const keepRanking = _extractKeepRanking(data);
openImportModal(file.name, sanitized, dropped, keepRanking);
} catch (err) {
setBackupStatus("import failed: " + err.message, "fail");
}
@@ -434,12 +693,21 @@ document.getElementById("import-file").addEventListener("change", async (e) => {
document.getElementById("import-modal-confirm").addEventListener("click", async () => {
if (!pendingImport) return;
const { sanitized, dropped } = pendingImport;
const { sanitized, dropped, keepRanking } = pendingImport;
try {
await chrome.storage.sync.set({ settings: sanitized });
let krNote = "";
if (keepRanking) {
try {
const r = await chrome.runtime.sendMessage({ type: "save-keep-ranking", keep_ranking: keepRanking });
krNote = r?.ok ? " + keep ranking" : ` (keep ranking failed: ${r?.error || "no response"})`;
} catch (e) {
krNote = ` (keep ranking failed: ${e.message || e})`;
}
}
chrome.runtime.sendMessage({ type: "settings-changed" });
closeImportModal();
setBackupStatus(`imported${dropped.length ? ` (${dropped.length} key(s) dropped)` : ""}. reloading...`, "ok");
setBackupStatus(`imported${krNote}${dropped.length ? ` (${dropped.length} key(s) dropped)` : ""}. reloading...`, "ok");
setTimeout(() => location.reload(), 600);
} catch (err) {
setBackupStatus("import failed: " + (err.message || String(err)), "fail");
@@ -529,8 +797,25 @@ function updateActivityFilterButtons() {
});
}
function updateActivityCounts() {
const entries = recentActivityEntries || [];
const counts = { all: entries.length, hit: 0, miss: 0, other: 0 };
for (const e of entries) {
if (e.outcome === "hit") counts.hit++;
else if (e.outcome === "miss") counts.miss++;
else if (e.outcome !== "no_id") counts.other++;
}
document.querySelectorAll("#activity-filters [data-activity-count]").forEach((el) => {
const key = el.dataset.activityCount;
const n = counts[key] ?? 0;
// Cap display at 999 so the 3-digit slot never overflows.
el.textContent = n > 999 ? "999+" : String(n);
});
}
function renderActivity(entries = recentActivityEntries) {
recentActivityEntries = entries || [];
updateActivityCounts();
const out = document.getElementById("activity-results");
if (!recentActivityEntries.length) {
out.innerHTML = `<span style="color:#777;">no recent activity yet</span>`;
@@ -1003,7 +1288,7 @@ document.getElementById("check-rcjav-path").addEventListener("click", async () =
(async () => {
const { optionsActivePane, pendingNativeSetupIssue } = await chrome.storage.local.get(["optionsActivePane", "pendingNativeSetupIssue"]);
activatePane(optionsActivePane || "triggers");
activatePane(optionsActivePane || "dupe-review");
await load();
if (pendingNativeSetupIssue) {
activatePane("diagnostics");
View File
View File
+17
View File
@@ -285,13 +285,26 @@ function renderResult(r) {
$undoBtn.style.display = (settings && settings.enableDelete && settings.deleteMode === "trash") ? "" : "none";
}
// Monotonic counter for in-flight searches. Profile selector changes or rapid
// re-searches can leave older RPCs inflight whose callbacks fire AFTER a newer
// search has started — without this gate, the stale callback would overwrite
// fresh UI with wrong-profile results. runCheck and runManualSearch both bump
// the counter on entry and capture their own id; callbacks compare against
// the current counter and bail if newer search has started.
let _currentSearchId = 0;
function runCheck(force = false) {
// Bump BEFORE the paused early-exit so any older inflight callback compares
// stale myId against the new counter and bails — otherwise pausing while a
// search is inflight would let the old callback overwrite the paused UI.
const myId = ++_currentSearchId;
if (settings && settings.scanPaused) { renderPausedState(); return; }
setStatus("Scanning…", "loading");
showSkeleton(2);
$deleteBtn.style.display = "none";
$undoBtn.style.display = "none";
chrome.runtime.sendMessage({ type: "check-tab", force }, (r) => {
if (myId !== _currentSearchId) return; // stale — newer search started
if (chrome.runtime.lastError) {
setStatus("error: " + chrome.runtime.lastError.message, "err");
$output.innerHTML = "";
@@ -443,6 +456,9 @@ let manualMode = false; // true while popup is showing manual-search results
function runManualSearch() {
const raw = $searchInput.value.trim();
if (!raw) return;
// Bump BEFORE the paused early-exit (see runCheck comment) so older inflight
// callbacks can't render after the user has triggered the paused state.
const myId = ++_currentSearchId;
if (settings && settings.scanPaused) { renderPausedState(); return; }
manualMode = true;
setStatus(`Searching ${raw}`, "loading");
@@ -456,6 +472,7 @@ function runManualSearch() {
id: raw,
quick: !!(settings && settings.quickMode),
}, (r) => {
if (myId !== _currentSearchId) return; // stale — newer search started
if (chrome.runtime.lastError) {
setStatus("error: " + chrome.runtime.lastError.message, "err");
$output.innerHTML = "";
+50
View File
@@ -0,0 +1,50 @@
// Shared JAV-ID extraction primitives.
// Loaded via importScripts() in the background service worker, and listed in
// manifest content_scripts[] BEFORE content.js so both contexts see RCJAV_IDS.
// Re-injection (chrome.scripting.executeScript) is safe — reassigning the
// namespace each call is idempotent.
(function (root) {
// Optional trailing letter (e.g. IBW-902z) is absorbed but not part of the ID.
// rc-jav's index drops trailing letters too, so the query "IBW-902" finds either.
const ID_RE_DASHED = /\b([A-Za-z][A-Za-z0-9]{1,})-(\d{2,7})[a-zA-Z]?\b/;
const ID_RE_UNDASHED = /\b([A-Za-z][A-Za-z0-9]{1,})(\d{3,5})[a-zA-Z]?\b/;
// Built-in studio normalizers — applied BEFORE the generic regex.
// User normalizers (settings.idNormalizers) are tried before these.
const BUILTIN_ID_NORMALIZERS = [
// FC2-PPV in any dash configuration: FC2PPV12345, FC2-PPV12345, FC2-PPV-12345
{ re: /\bFC2-?PPV-?(\d{4,})\b/i, fmt: "FC2-PPV-$1" },
// Some sites display FC2 IDs without the PPV segment: FC2-1841460.
{ re: /\bFC2-(\d{4,})\b/i, fmt: "FC2-PPV-$1" },
];
function applyNormalizers(text, userList) {
const all = [...(userList || []), ...BUILTIN_ID_NORMALIZERS];
for (const n of all) {
let re;
try { re = n.re instanceof RegExp ? n.re : new RegExp(n.re, "i"); } catch { continue; }
const m = text.match(re);
if (m) return n.fmt.replace(/\$(\d)/g, (_, i) => m[+i] || "");
}
return null;
}
function normalizeId(text, userList) {
if (!text) return null;
const fromNormalizer = applyNormalizers(text, userList);
if (fromNormalizer) return fromNormalizer.toUpperCase();
let m = text.match(ID_RE_DASHED);
if (!m) m = text.match(ID_RE_UNDASHED);
if (!m) return null;
return `${m[1].toUpperCase()}-${m[2]}`;
}
root.RCJAV_IDS = {
BUILTIN_ID_NORMALIZERS,
ID_RE_DASHED,
ID_RE_UNDASHED,
applyNormalizers,
normalizeId,
};
})(typeof self !== "undefined" ? self : globalThis);