diff --git a/.gitignore b/.gitignore index e0af246..c7ea98e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ host/__pycache__/ host/logs/ host/state/ +*.bak +rclone-jav-library-issues-*.json +.vscode/ +.idea/ diff --git a/CLAUDE.md.bak b/CLAUDE.md.bak deleted file mode 100644 index e6dd23e..0000000 --- a/CLAUDE.md.bak +++ /dev/null @@ -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 diff --git a/background.js b/background.js index db6dfad..007c7c2 100644 --- a/background.js +++ b/background.js @@ -1,6 +1,10 @@ // rclonex background service worker. // Owns the native-messaging port, dispatches lookups, draws badge/notifications. +// Shared ID-extraction primitives. Same file is listed in manifest +// content_scripts[] for the content script context. +importScripts("src/shared/id-extract.js"); + const HOST_NAME = "com.rcjav.host"; const DEFAULT_SETTINGS = { @@ -44,6 +48,10 @@ const DEFAULT_SETTINGS = { // Empty array = no profiles defined (use rc-jav config.json defaults). profiles: [], // [{name: string, source: string[], target: string[]}] activeProfile: "", // name of active profile; "" = use config.json defaults + // Out-of-band alerts. Empty URL disables. PC label identifies which machine + // fired the alert when both PCs share settings via sync. + discordWebhookUrl: "", + pcLabel: "", }; // ----- settings helpers ----- @@ -122,10 +130,20 @@ function ensurePort() { }); nativePort.onDisconnect.addListener(() => { const err = chrome.runtime.lastError; + const msg = err?.message || "host disconnected"; console.warn("rclonex: native host disconnected", err); nativePort = null; - for (const [, p] of pending) p.reject(new Error(err?.message || "host disconnected")); + // Reject inflight pending — each gets its own log entry via the reject + // wrapper installed in nativeCall. + const inflight = pending.size; + for (const [, p] of pending) p.reject(new Error(msg)); pending.clear(); + // Also log a marker so the user can see WHEN the port itself dropped + // (independent of any specific in-flight call). + recordRpc({ + ts: Date.now(), action: "__port_disconnect__", ok: false, latency_ms: 0, + error: msg, error_kind: "disconnected", inflight, + }); }); return nativePort; } @@ -134,20 +152,227 @@ function ensurePort() { // disconnecting the port over the 1 MiB cap) doesn't leave UI spinners forever. const NATIVE_CALL_TIMEOUT_MS = 90_000; +// Rolling RPC log so the user can diagnose intermittent "Error when communicating +// with the native messaging host" failures (host crash, port disconnect, 1 MiB +// response cap, SW recycle). Persisted in chrome.storage.local because the SW +// can be evicted between the call and the user opening Diagnostics. +const NATIVE_LOG_KEY = "rclonejavNativeLog"; +const NATIVE_LOG_MAX = 200; + +// Serialize concurrent recordRpc writes via a promise-chain lock. Chrome +// storage.local has no atomic-append guarantee; without serialization, two +// callers in the same tick (e.g. onDisconnect rejecting N pending requests +// alongside the __port_disconnect__ marker) each do get-then-set against the +// same stale old-value and last-write-wins eats intermediate entries. +// Pattern mirrors the tabvault _rcjavTrace lock and the ensureContextMenu +// lock for the same class of read-modify-write race on extension storage. +let _rpcLogLock = Promise.resolve(); +function recordRpc(entry) { + _rpcLogLock = _rpcLogLock.then(async () => { + try { + const got = await chrome.storage.local.get(NATIVE_LOG_KEY); + const old = Array.isArray(got[NATIVE_LOG_KEY]) ? got[NATIVE_LOG_KEY] : []; + await chrome.storage.local.set({ + [NATIVE_LOG_KEY]: [entry, ...old].slice(0, NATIVE_LOG_MAX), + }); + } catch (_) { /* storage full / SW dying — never break the lock chain */ } + }); + // Out-of-band alert path runs OUTSIDE the lock — its own internal rate-limit + // file (HOST_ALERT_KEY) has a separate race tracked as L-1 in the queue. + // Notification sticks in Windows Action Center until dismissed + // (requireInteraction:true) so the user catches it after sleep or hours away. + maybeNotifyHostError(entry); + return _rpcLogLock; +} + +const ALARM_ERROR_KINDS = new Set([ + "disconnected", "timeout", "post_failed", "exception", "host_error", +]); +const HOST_ALERT_KEY = "rclonejavLastHostAlertTs"; +const HOST_ALERT_MIN_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes + +// Serialize the rate-limit read/check/write through a dedicated lock (NOT +// shared with _rpcLogLock — different storage key, different invariant). On a +// burst like onDisconnect rejecting N pending requests in the same tick, all +// callers reached this without serialization and each read the same stale +// lastTs, all passed the < HOST_ALERT_MIN_INTERVAL_MS check, all wrote a new +// ts, all fired a notification + Discord post. With the lock, only the first +// caller's check passes; the rest read the freshly-written ts and bail. +let _hostAlertLock = Promise.resolve(); + +function maybeNotifyHostError(entry) { + if (!entry || entry.ok) return; + const kind = entry.error_kind || ""; + if (!ALARM_ERROR_KINDS.has(kind)) return; + // __port_disconnect__ fires alongside per-call rejects — coalesce by keying + // the rate-limit on the wall clock; both will hit the same window. + _hostAlertLock = _hostAlertLock.then(async () => { + try { + const now = Date.now(); + const got = await chrome.storage.local.get(HOST_ALERT_KEY); + const lastTs = Number(got[HOST_ALERT_KEY]) || 0; + if (now - lastTs < HOST_ALERT_MIN_INTERVAL_MS) return; + await chrome.storage.local.set({ [HOST_ALERT_KEY]: now }); + const errText = (entry.error || "").slice(0, 200); + const actionText = entry.action === "__port_disconnect__" + ? `Port disconnected (${entry.inflight || 0} inflight)` + : `Action: ${entry.action || "?"}`; + chrome.notifications.create( + "rclonejav-host-error-" + now, + { + type: "basic", + iconUrl: chrome.runtime.getURL("icons/icon-128.png"), + title: `rclone-jav: native host ${kind}`, + message: `${actionText}\n${errText}`, + contextMessage: "Click to open Diagnostics", + requireInteraction: true, + priority: 2, + }, + () => { void chrome.runtime.lastError; }, + ); + // Discord webhook — out-of-band channel for "away from PC" coverage. + // Same rate limit applies (we just consumed it above). Fire-and-forget + // so the lock releases quickly; postDiscordAlert is already non-blocking + // (fetch is sync-to-call, awaits the network in background). + const settings = await getSettings(); + if (settings.discordWebhookUrl) { + postDiscordAlert(entry, settings).catch(() => {}); + } + } catch (_) { /* never break the lock chain */ } + }); + return _hostAlertLock; +} + +// Discord webhook colors (decimal RGB). Red for disconnects/exceptions, orange +// for timeouts, yellow for host-side ok:false (less severe — host responded). +const DISCORD_COLOR_BY_KIND = { + disconnected: 0xff5050, // red + exception: 0xff5050, + post_failed: 0xff5050, + timeout: 0xffa500, // orange + host_error: 0xffd400, // yellow +}; + +async function postDiscordAlert(entry, settings) { + const url = (settings.discordWebhookUrl || "").trim(); + if (!url || !/^https:\/\/(?:discord\.com|discordapp\.com)\/api\/webhooks\//.test(url)) return; + const kind = entry.error_kind || "unknown"; + const color = DISCORD_COLOR_BY_KIND[kind] ?? 0xff5050; + // Wrap raw identifiers in inline code so Discord doesn't render symbols like + // __ or * as Markdown (e.g. `__port_disconnect__` would italicize otherwise). + // Only backticks need escaping inside inline code. + const codeInline = (s) => "`" + String(s).replace(/`/g, "ˋ") + "`"; + // Errors can contain backticks too — fence as a multiline code block so any + // markdown chars (* _ ~ | > \) render literally. + const codeBlock = (s) => "```\n" + String(s).replace(/```/g, "``​`") + "\n```"; + const fields = []; + if (entry.action) fields.push({ name: "Action", value: codeInline(String(entry.action).slice(0, 100)), inline: true }); + if (entry.latency_ms != null) fields.push({ name: "Latency", value: `${entry.latency_ms}ms`, inline: true }); + if (entry.inflight != null) fields.push({ name: "Inflight", value: String(entry.inflight), inline: true }); + if (settings.pcLabel) fields.push({ name: "PC", value: codeInline(String(settings.pcLabel).slice(0, 60)), inline: true }); + const errText = (entry.error || "").slice(0, 1800); + const payload = { + username: "rclone-jav", + embeds: [{ + title: `Native host ${kind}`, + description: errText ? codeBlock(errText) : "(no error message)", + color, + timestamp: new Date(entry.ts || Date.now()).toISOString(), + fields, + footer: { text: "rclone-jav extension" }, + }], + }; + try { + const resp = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + await chrome.storage.local.set({ + lastDiscordSend: { ts: Date.now(), status: resp.status, ok: resp.ok }, + }); + } catch (e) { + await chrome.storage.local.set({ + lastDiscordSend: { ts: Date.now(), status: 0, ok: false, error: e.message || String(e) }, + }); + } +} + +// Allow the Options page to trigger a test post without faking an error. +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg && msg.type === "test-discord-webhook") { + (async () => { + const settings = await getSettings(); + if (!settings.discordWebhookUrl) { + sendResponse({ ok: false, error: "no webhook URL configured" }); + return; + } + await postDiscordAlert({ + ts: Date.now(), + action: "test-alert", + error_kind: "host_error", + error: "Test alert from Options → Setup → Alerts. If you see this in Discord, the webhook works.", + latency_ms: 0, + }, settings); + const got = await chrome.storage.local.get("lastDiscordSend"); + sendResponse({ ok: got.lastDiscordSend?.ok, status: got.lastDiscordSend?.status, error: got.lastDiscordSend?.error }); + })(); + return true; // async sendResponse + } +}); + +chrome.notifications.onClicked.addListener(async (notificationId) => { + if (!notificationId.startsWith("rclonejav-host-error-")) return; + try { + await chrome.notifications.clear(notificationId); + await chrome.storage.local.set({ optionsActivePane: "diagnostics" }); + await chrome.runtime.openOptionsPage(); + } catch (_) { /* user dismissed before we got here */ } +}); + function nativeCall(payload, timeoutMs = NATIVE_CALL_TIMEOUT_MS) { const port = ensurePort(); const reqId = nextReqId++; const msg = Object.assign({ req_id: reqId }, payload); + const action = payload.action || "?"; + const startedAt = Date.now(); return new Promise((resolve, reject) => { const timer = setTimeout(() => { if (pending.has(reqId)) { pending.delete(reqId); - reject(new Error(`native host timeout after ${timeoutMs}ms (action=${payload.action || "?"})`)); + const err = new Error(`native host timeout after ${timeoutMs}ms (action=${action})`); + recordRpc({ + ts: startedAt, action, ok: false, latency_ms: Date.now() - startedAt, + error: err.message, error_kind: "timeout", + }); + reject(err); } }, timeoutMs); pending.set(reqId, { - resolve: (v) => { clearTimeout(timer); resolve(v); }, - reject: (e) => { clearTimeout(timer); reject(e); }, + resolve: (v) => { + clearTimeout(timer); + let respBytes = -1; + try { respBytes = JSON.stringify(v).length; } catch {} + recordRpc({ + ts: startedAt, action, ok: !!(v && v.ok), + latency_ms: Date.now() - startedAt, + resp_bytes: respBytes, + truncated: !!(v && v.truncated), + truncated_reason: v && v.truncated_reason || "", + error: v && !v.ok ? (v.error || "") : null, + error_kind: v && !v.ok ? (v.error_kind || "host_error") : null, + }); + resolve(v); + }, + reject: (e) => { + clearTimeout(timer); + recordRpc({ + ts: startedAt, action, ok: false, latency_ms: Date.now() - startedAt, + error: e && e.message || String(e), + error_kind: classifyNativeError(e), + }); + reject(e); + }, }); ensureKeepalive(); try { port.postMessage(msg); } @@ -157,6 +382,10 @@ function nativeCall(payload, timeoutMs = NATIVE_CALL_TIMEOUT_MS) { // The port is in a broken state — null it so the next call reconnects // instead of reusing the dead handle and failing the same way. if (nativePort === port) nativePort = null; + recordRpc({ + ts: startedAt, action, ok: false, latency_ms: Date.now() - startedAt, + error: e && e.message || String(e), error_kind: "post_failed", + }); reject(e); } }); @@ -171,30 +400,146 @@ function classifyNativeError(err) { return "unknown"; } +// Active profile's source/target overrides, or empty arrays if no profile selected. +function profileOverrides(settings) { + const prof = (settings.profiles || []).find((p) => p.name === settings.activeProfile); + return { + source_override: prof ? (prof.source || []) : [], + target_override: prof ? (prof.target || []) : [], + }; +} + +// Factory for table-driven native-message handlers. `buildPayload(msg, settings)` +// returns the payload to forward (rcjav_path is filled from settings if absent). +// Opts: timeout, paused (bail if scanPaused), requireDelete (bail if !enableDelete). +function nativeProxy(buildPayload, opts = {}) { + return (msg, sendResponse) => { + (async () => { + try { + const settings = await getSettings(); + if (opts.paused && settings.scanPaused) { + sendResponse({ ok: false, paused: true, error: "scanning paused" }); + return; + } + if (opts.requireDelete && !settings.enableDelete) { + sendResponse({ ok: false, error: "deletion is disabled in options" }); + return; + } + const payload = buildPayload(msg, settings) || {}; + if (!("rcjav_path" in payload)) payload.rcjav_path = settings.rcjavPath || ""; + const r = await nativeCall(payload, opts.timeout || NATIVE_CALL_TIMEOUT_MS); + sendResponse(r); + } catch (e) { + sendResponse({ + ok: false, + error: e.message, + error_kind: classifyNativeError(e), + extension_id: chrome.runtime.id, + }); + } + })(); + }; +} + +// Native-action proxies. Each entry forwards a fixed action to the host with a +// small per-call payload. Hand-written handlers (check-tab, manual-query, +// bulk-query, picker, activity log, settings) live in the onMessage listener. +const NATIVE_PROXIES = { + "dupe-review": nativeProxy( + (_msg, s) => ({ action: "dupe_review", part_patterns: s.partPatterns || [], ...profileOverrides(s) }), + { timeout: 600_000 }, + ), + "library_issues": nativeProxy(() => ({ action: "library_issues" }), { timeout: 60_000 }), + "rename_file": nativeProxy( + (msg) => ({ action: "rename_file", remote: msg.remote, old_path: msg.old_path, new_path: msg.new_path }), + { timeout: 60_000 }, + ), + "rename_files_batch": nativeProxy( + (msg) => ({ action: "rename_files_batch", renames: msg.renames }), + { timeout: 300_000 }, + ), + + // Deletion (requires opt-in setting) + "delete-file": nativeProxy( + (msg, s) => ({ action: "delete", path: msg.path, mode: s.deleteMode || "trash", trash_dir: s.trashDir || "" }), + { requireDelete: true }, + ), + "delete_batch": nativeProxy( + (msg, s) => ({ action: "delete_batch", paths: msg.paths, mode: s.deleteMode || "trash", trash_dir: s.trashDir || "" }), + { timeout: 600_000, requireDelete: true }, + ), + "delete-skipped": nativeProxy( + (msg, s) => ({ action: "delete_skipped", paths: msg.paths, mode: s.deleteMode || "trash", trash_dir: s.trashDir || "" }), + { timeout: 120_000, requireDelete: true }, + ), + "recent-deletes": nativeProxy((msg) => ({ action: "recent_deletes", limit: msg.limit || 20 })), + "undo-delete": nativeProxy( + (msg) => ({ action: "undo_delete", dst: msg.dst, path: msg.path }), + { requireDelete: true }, + ), + + // Keep-ranking editor + "get-keep-ranking": nativeProxy(() => ({ action: "get_keep_ranking" }), { timeout: 10_000 }), + "save-keep-ranking": nativeProxy( + (msg) => ({ action: "save_keep_ranking", keep_ranking: msg.keep_ranking }), + { timeout: 10_000 }, + ), + + // Diagnostics / host status + "diagnostics": nativeProxy((_msg, s) => ({ + action: "diagnostics", + extension_settings: { + quick_mode: s.quickMode !== false, + active_profile: s.activeProfile || "", + profile_count: (s.profiles || []).length, + auto_every_page: !!s.triggers?.autoEveryPage, + auto_known_sites: !!s.triggers?.autoKnownSites, + }, + })), + "host-status": nativeProxy(() => ({ action: "host_status", extension_id: chrome.runtime.id }), { timeout: 20_000 }), + "repair-host": nativeProxy(() => ({ action: "host_repair", extension_id: chrome.runtime.id }), { timeout: 20_000 }), + "ping-host": nativeProxy((msg, s) => ({ action: "ping", rcjav_path: msg.rcjavPath ?? s.rcjavPath ?? "" })), + + // Cache contract + "cache-status": nativeProxy((_msg, s) => ({ action: "cache_status", stale_hours: s.cacheStaleHours || 24 })), + "reextract-ids": nativeProxy(() => ({ action: "reextract_ids" }), { timeout: 300_000 }), + + // Scans + "run-scan": nativeProxy( + (msg, s) => ({ + action: "scan", + scan_since: msg.scanSince || "", + scan_roots: Array.isArray(msg.scanRoots) ? msg.scanRoots : [], + part_patterns: s.partPatterns || [], + timeout: 600, + }), + { timeout: 600_000, paused: true }, + ), + "scan-cancel": nativeProxy(() => ({ action: "scan_cancel" }), { timeout: 5_000 }), + "scan-progress": nativeProxy(() => ({ action: "scan_progress" }), { timeout: 5_000 }), + "scan-clear": nativeProxy(() => ({ action: "scan_clear" }), { timeout: 5_000 }), + "list-remotes": nativeProxy(() => ({ action: "list_remotes" }), { timeout: 20_000 }), + "clear-events-log": nativeProxy(() => ({ action: "clear_events_log" }), { timeout: 5_000 }), + "save-alerts-config": nativeProxy( + (msg) => ({ + action: "save_alerts_config", + discord_webhook_url: msg.discordWebhookUrl || "", + pc_label: msg.pcLabel || "", + }), + { timeout: 5_000 }, + ), + "test-host-alert": nativeProxy(() => ({ action: "test_alerts_config" }), { timeout: 10_000 }), +}; + // ----- ID extraction ----- // Ask the content script (which has DOM + adapters). Fall back to tab.title if injection -// hasn't run (e.g. chrome:// pages). - -const BUILTIN_ID_NORMALIZERS = [ - { re: /\bFC2-?PPV-?(\d{4,})\b/i, fmt: "FC2-PPV-$1" }, - { 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; -} +// hasn't run (e.g. chrome:// pages). Core regexes + normalizers live in +// src/shared/id-extract.js so background and content stay in lockstep. function traceTextExtraction(text, userList) { const raw = String(text || ""); if (!raw) return { id: null, source: "none", raw: "" }; - const all = [...(userList || []), ...BUILTIN_ID_NORMALIZERS]; + const all = [...(userList || []), ...RCJAV_IDS.BUILTIN_ID_NORMALIZERS]; for (let index = 0; index < all.length; index++) { const n = all[index]; let re; @@ -210,25 +555,16 @@ function traceTextExtraction(text, userList) { replacement: n.fmt, }; } - let m = raw.match(/\b([A-Za-z][A-Za-z0-9]{1,})-(\d{2,7})[a-zA-Z]?\b/); - if (!m) m = raw.match(/\b([A-Za-z][A-Za-z0-9]{1,})(\d{3,5})[a-zA-Z]?\b/); - return m ? { - id: `${m[1].toUpperCase()}-${m[2]}`, - source: "generic ID regex", - raw: raw.slice(0, 300), - } : { id: null, source: "none", raw: raw.slice(0, 300) }; + const id = RCJAV_IDS.normalizeId(raw); + return id + ? { id, source: "generic ID regex", raw: raw.slice(0, 300) } + : { id: null, source: "none", raw: raw.slice(0, 300) }; } async function extractIdFromTitle(title) { if (!title) return null; const { settings = {} } = await chrome.storage.sync.get("settings"); - const fromNormalizer = applyNormalizers(title, settings.idNormalizers || []); - if (fromNormalizer) return fromNormalizer.toUpperCase(); - // Optional trailing letter (e.g. IBW-902z) absorbed but not part of the ID. - let m = title.match(/\b([A-Za-z][A-Za-z0-9]{1,})-(\d{2,7})[a-zA-Z]?\b/); - if (!m) m = title.match(/\b([A-Za-z][A-Za-z0-9]{1,})(\d{3,5})[a-zA-Z]?\b/); - if (!m) return null; - return `${m[1].toUpperCase()}-${m[2]}`; + return RCJAV_IDS.normalizeId(title, settings.idNormalizers || []); } async function extractIdFromTab(tab) { @@ -274,15 +610,26 @@ function summarizeActivity(tab, result, trigger = "page") { }; } +// Same Promise-chain lock pattern as `_rpcLogLock` (M-6). recordActivity +// gets called concurrently from multiple paths (autocheck on tab events, +// manual search, context-menu selection search, batch checks) and shares +// the same chrome.storage.local read-modify-write race. Mirror fix for M-6 +// caught during Phase 3 verification. +let _activityLogLock = Promise.resolve(); async function recordActivity(tab, result, trigger = "page") { - try { - const row = summarizeActivity(tab, result, trigger); - const got = await chrome.storage.local.get(ACTIVITY_KEY); - const old = Array.isArray(got[ACTIVITY_KEY]) ? got[ACTIVITY_KEY] : []; - await chrome.storage.local.set({ [ACTIVITY_KEY]: [row, ...old].slice(0, ACTIVITY_MAX) }); - } catch {} + const row = summarizeActivity(tab, result, trigger); + // Skip no_id outcomes — every page lacking a JAV ID would otherwise flood + // the log on auto-every-page mode and drown out real hits/misses. + if (row.outcome === "no_id") return; + _activityLogLock = _activityLogLock.then(async () => { + try { + const got = await chrome.storage.local.get(ACTIVITY_KEY); + const old = Array.isArray(got[ACTIVITY_KEY]) ? got[ACTIVITY_KEY] : []; + await chrome.storage.local.set({ [ACTIVITY_KEY]: [row, ...old].slice(0, ACTIVITY_MAX) }); + } catch {} + }); + return _activityLogLock; } - async function persistTabResult(tab, result) { if (!tab || tab.id == null) return; try { @@ -347,7 +694,6 @@ async function checkTab(tab) { let result; try { const nativeStart = performance.now(); - const prof = (settings.profiles || []).find((p) => p.name === settings.activeProfile); result = await nativeCall({ action: "search", id, @@ -355,8 +701,7 @@ async function checkTab(tab) { stale_hours: settings.cacheStaleHours || 24, rcjav_path: settings.rcjavPath || "", part_patterns: settings.partPatterns || [], - source_override: prof ? (prof.source || []) : [], - target_override: prof ? (prof.target || []) : [], + ...profileOverrides(settings), }); result.timings = Object.assign({}, result.timings || {}, { extract_ms: extractMs, @@ -391,16 +736,32 @@ async function checkTab(tab) { return r; } +// Suppress "No tab with id: N" rejections that fire when the user closes the +// tab mid-operation. Any other unexpected error is logged but not re-thrown so +// the spinner loop doesn't kill itself. +function _swallowTabGone(p) { + if (p && typeof p.catch === "function") { + p.catch((e) => { + if (e && /No tab with id/i.test(e.message || "")) return; + console.warn("[rclone-jav] tab op failed:", e); + }); + } +} + function startBadgeSpinner(tabId) { if (typeof tabId !== "number") return; stopBadgeSpinner(tabId); const frames = ["-", "\\", "|", "/"]; let i = 0; - chrome.action.setBadgeBackgroundColor({ tabId, color: "#446" }); - chrome.action.setBadgeText({ tabId, text: frames[i] }); + _swallowTabGone(chrome.action.setBadgeBackgroundColor({ tabId, color: "#446" })); + _swallowTabGone(chrome.action.setBadgeText({ tabId, text: frames[i] })); const handle = setInterval(() => { i = (i + 1) % frames.length; - chrome.action.setBadgeText({ tabId, text: frames[i] }); + // Stop firing once the tab is gone — keeps the interval from spamming + // errors every 180ms after the user closed the tab. + chrome.action.setBadgeText({ tabId, text: frames[i] }).catch((e) => { + if (e && /No tab with id/i.test(e.message || "")) stopBadgeSpinner(tabId); + }); }, 180); badgeSpinners.set(tabId, handle); } @@ -415,8 +776,8 @@ function stopBadgeSpinner(tabId) { function setBadge(tabId, text, color) { if (typeof tabId !== "number") return; - chrome.action.setBadgeText({ tabId, text }); - chrome.action.setBadgeBackgroundColor({ tabId, color }); + _swallowTabGone(chrome.action.setBadgeText({ tabId, text })); + _swallowTabGone(chrome.action.setBadgeBackgroundColor({ tabId, color })); } function notify(title, message) { @@ -436,17 +797,38 @@ function notify(title, message) { // ----- triggers ----- // Context menu -async function ensureContextMenu() { - const s = await getSettings(); - chrome.contextMenus.removeAll(() => { - if (s.triggers.contextMenu) { - chrome.contextMenus.create({ - id: "rclonex-check-page", - title: "rclone-jav: Scan", - contexts: ["page", "link", "selection"], - }); - } +// Serialize concurrent ensureContextMenu calls. With the M-2 fix added a +// top-level call at module init, it can race with the settings-changed / +// onInstalled / onStartup handlers — two overlapping removeAll → create +// cycles produce "Cannot create item with duplicate id" because the second +// create runs while the first's items haven't been removed yet. The +// Promise-chain lock keeps invocations strictly sequential. +let _contextMenuLock = Promise.resolve(); +function ensureContextMenu() { + _contextMenuLock = _contextMenuLock.then(async () => { + try { + const s = await getSettings(); + await new Promise((resolve) => + chrome.contextMenus.removeAll(() => { void chrome.runtime.lastError; resolve(); }) + ); + if (!s.triggers.contextMenu) return; + try { + chrome.contextMenus.create({ + id: "rclonex-check-page", + title: "rclone-jav: Scan", + contexts: ["page", "link", "selection"], + }); + } catch (_) { void chrome.runtime.lastError; } + try { + chrome.contextMenus.create({ + id: "rclonex-search-selection", + title: "rclone-jav: Search \"%s\"", + contexts: ["selection"], + }); + } catch (_) { void chrome.runtime.lastError; } + } catch (_) { /* never break the lock chain */ } }); + return _contextMenuLock; } async function stashAndShow(r) { @@ -497,11 +879,79 @@ async function sendOverlay(tab, r, s) { } catch (e) { /* content script may not be present on chrome:// pages */ } } +async function searchSelection(tab, selectionText) { + if (!tab || typeof selectionText !== "string") return null; + const text = selectionText.trim(); + if (!text) return null; + const settings = await getSettings(); + if (settings.scanPaused) { + if (tab.id != null) setBadge(tab.id, "Ⅱ", "#66551f"); + return { ok: false, paused: true, reason: "scanning paused" }; + } + const t0 = performance.now(); + startBadgeSpinner(tab.id); + // Try to extract a canonical JAV ID from the selection. If it parses, search + // by id (cache prefix lookup). Otherwise fall back to --name substring search. + const normalized = RCJAV_IDS.normalizeId(text, settings.idNormalizers || []); + let result; + try { + const nativeStart = performance.now(); + result = await nativeCall({ + action: "search", + ...(normalized ? { id: normalized } : { name: text }), + quick: !!settings.quickMode, + stale_hours: settings.cacheStaleHours || 24, + rcjav_path: settings.rcjavPath || "", + part_patterns: settings.partPatterns || [], + ...profileOverrides(settings), + }); + result.timings = Object.assign({}, result.timings || {}, { + native_ms: Math.round(performance.now() - nativeStart), + total_ms: Math.round(performance.now() - t0), + }); + } catch (e) { + stopBadgeSpinner(tab.id); + setBadge(tab.id, "!", "#c33"); + const r = { + ok: false, + reason: "host error: " + e.message, + error: e.message, + error_kind: classifyNativeError(e), + extension_id: chrome.runtime.id, + id: normalized || text, + }; + await persistTabResult(tab, r); + await recordActivity(tab, r, "selection"); + return r; + } + const hits = result.hits || 0; + stopBadgeSpinner(tab.id); + if (result.ok) { + setBadge(tab.id, hits > 0 ? String(hits) : "✗", hits > 0 ? "#3a7" : "#a33"); + } else { + setBadge(tab.id, "!", "#c33"); + } + const r = Object.assign({ + id: normalized || text, + selection: text, + cached: false, + ts: Date.now(), + }, result); + await persistTabResult(tab, r); + await recordActivity(tab, r, "selection"); + return r; +} + chrome.contextMenus.onClicked.addListener(async (info, tab) => { - if (info.menuItemId !== "rclonex-check-page" || !tab) return; + if (!tab) return; const s = await getSettings(); - const r = await checkTab(tab); - await sendOverlay(tab, r, s); + if (info.menuItemId === "rclonex-check-page") { + const r = await checkTab(tab); + await sendOverlay(tab, r, s); + } else if (info.menuItemId === "rclonex-search-selection") { + const r = await searchSelection(tab, info.selectionText); + if (r) await sendOverlay(tab, r, s); + } }); // Keyboard @@ -591,6 +1041,12 @@ function matchesKnownSite(url, patterns) { // ----- popup / options bridge ----- chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + // Table-driven native-action proxies handle the bulk of host forwarding. + // Hand-written handlers (below) cover anything with custom logic — tab lookup, + // activity recording, picker injection, settings round-trip, etc. + const proxy = NATIVE_PROXIES[msg.type]; + if (proxy) { proxy(msg, sendResponse); return true; } + if (msg.type === "check-tab") { (async () => { let tab; @@ -641,7 +1097,6 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { sendResponse({ ok: false, paused: true, error: "scanning paused" }); return; } - const prof = (settings.profiles || []).find((p) => p.name === settings.activeProfile); const nativeStart = performance.now(); const r = await nativeCall({ action: msg.action || "search", @@ -651,8 +1106,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { stale_hours: settings.cacheStaleHours || 24, rcjav_path: settings.rcjavPath || "", part_patterns: settings.partPatterns || [], - source_override: prof ? (prof.source || []) : [], - target_override: prof ? (prof.target || []) : [], + ...profileOverrides(settings), }); r.timings = Object.assign({}, r.timings || {}, { native_ms: Math.round(performance.now() - nativeStart), @@ -676,7 +1130,6 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { sendResponse({ ok: false, paused: true, error: "scanning paused" }); return; } - const prof = (settings.profiles || []).find((p) => p.name === settings.activeProfile); const r = await nativeCall({ action: "bulk_search", queries: msg.queries || [], @@ -684,8 +1137,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { stale_hours: settings.cacheStaleHours || 24, rcjav_path: settings.rcjavPath || "", part_patterns: settings.partPatterns || [], - source_override: prof ? (prof.source || []) : [], - target_override: prof ? (prof.target || []) : [], + ...profileOverrides(settings), }, 600_000); sendResponse(r); } catch (e) { sendResponse({ ok: false, error: e.message, error_kind: classifyNativeError(e), extension_id: chrome.runtime.id }); } @@ -697,130 +1149,6 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { sendResponse({ ok: true }); return false; } - if (msg.type === "dupe-review") { - (async () => { - try { - const settings = await getSettings(); - const prof = (settings.profiles || []).find((p) => p.name === settings.activeProfile); - const r = await nativeCall({ - action: "dupe_review", - rcjav_path: settings.rcjavPath || "", - part_patterns: settings.partPatterns || [], - source_override: prof ? (prof.source || []) : [], - target_override: prof ? (prof.target || []) : [], - }, 600_000); - sendResponse(r); - } catch (e) { sendResponse({ ok: false, error: e.message }); } - })(); - return true; - } - if (msg.type === "delete_batch") { - (async () => { - try { - const settings = await getSettings(); - if (!settings.enableDelete) { - sendResponse({ ok: false, error: "deletion is disabled in options" }); - return; - } - const r = await nativeCall({ - action: "delete_batch", - paths: msg.paths, - mode: settings.deleteMode || "trash", - trash_dir: settings.trashDir || "", - rcjav_path: settings.rcjavPath || "", - }, 600_000); - sendResponse(r); - } catch (e) { sendResponse({ ok: false, error: e.message }); } - })(); - return true; - } - if (msg.type === "delete-skipped") { - (async () => { - try { - const settings = await getSettings(); - if (!settings.enableDelete) { - sendResponse({ ok: false, error: "deletion is disabled in options" }); - return; - } - const r = await nativeCall({ - action: "delete_skipped", - paths: msg.paths, - mode: settings.deleteMode || "trash", - trash_dir: settings.trashDir || "", - rcjav_path: settings.rcjavPath || "", - }, 120_000); - sendResponse(r); - } catch (e) { sendResponse({ ok: false, error: e.message }); } - })(); - return true; - } - if (msg.type === "get-keep-ranking") { - (async () => { - try { - const settings = await getSettings(); - const r = await nativeCall({ action: "get_keep_ranking", rcjav_path: settings.rcjavPath || "" }, 10_000); - sendResponse(r); - } catch (e) { sendResponse({ ok: false, error: e.message }); } - })(); - return true; - } - if (msg.type === "save-keep-ranking") { - (async () => { - try { - const settings = await getSettings(); - const r = await nativeCall({ - action: "save_keep_ranking", - rcjav_path: settings.rcjavPath || "", - keep_ranking: msg.keep_ranking, - }, 10_000); - sendResponse(r); - } catch (e) { sendResponse({ ok: false, error: e.message }); } - })(); - return true; - } - if (msg.type === "library_issues") { - (async () => { - try { - const settings = await getSettings(); - const r = await nativeCall({ - action: "library_issues", - rcjav_path: settings.rcjavPath || "", - }, 60_000); - sendResponse(r); - } catch (e) { sendResponse({ ok: false, error: e.message }); } - })(); - return true; - } - if (msg.type === "rename_file") { - (async () => { - try { - const settings = await getSettings(); - const r = await nativeCall({ - action: "rename_file", - rcjav_path: settings.rcjavPath || "", - remote: msg.remote, - old_path: msg.old_path, - new_path: msg.new_path, - }, 60_000); - sendResponse(r); - } catch (e) { sendResponse({ ok: false, error: e.message }); } - })(); - return true; - } - if (msg.type === "rename_files_batch") { - (async () => { - try { - const settings = await getSettings(); - const r = await nativeCall({ - action: "rename_files_batch", - rcjav_path: settings.rcjavPath || "", - renames: msg.renames, - }, 300_000); - sendResponse(r); - } catch (e) { sendResponse({ ok: false, error: e.message }); } - })(); - return true; - } if (msg.type === "start-picker") { (async () => { // Find most-recently-active normal web tab (not the options page itself). @@ -839,7 +1167,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { // Force-inject content.js — handles tabs that were opened before the extension // (or before its most recent reload). try { - await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["content.js"] }); + await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["src/shared/id-extract.js", "content.js"] }); } catch (e) { /* may fail on chrome:// pages, fine */ } await chrome.tabs.sendMessage(tab.id, { type: "start-picker" }); pendingPicker = { tabId: tab.id, sender: msg.from || "options" }; @@ -860,7 +1188,7 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { } try { try { - await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["content.js"] }); + await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ["src/shared/id-extract.js", "content.js"] }); } catch (e) { /* content script may already be present */ } const extracted = await chrome.tabs.sendMessage(tab.id, { type: "trace-extract-id", @@ -890,210 +1218,30 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { sendResponse({ ok: true }); return false; } - if (msg.type === "delete-file") { - (async () => { - const settings = await getSettings(); - if (!settings.enableDelete) { - sendResponse({ ok: false, error: "deletion is disabled in options" }); - return; - } - try { - const r = await nativeCall({ - action: "delete", - path: msg.path, - mode: settings.deleteMode || "trash", - trash_dir: settings.trashDir || "", - rcjav_path: settings.rcjavPath || "", - }); - sendResponse(r); - } catch (e) { sendResponse({ ok: false, error: e.message }); } - })(); - return true; - } if (msg.type === "get-settings") { (async () => sendResponse(await getSettings()))(); return true; } - if (msg.type === "diagnostics") { - (async () => { - try { - const settings = await getSettings(); - const r = await nativeCall({ - action: "diagnostics", - rcjav_path: settings.rcjavPath || "", - extension_settings: { - quick_mode: settings.quickMode !== false, - active_profile: settings.activeProfile || "", - profile_count: (settings.profiles || []).length, - auto_every_page: !!settings.triggers?.autoEveryPage, - auto_known_sites: !!settings.triggers?.autoKnownSites, - }, - }); - sendResponse(r); - } catch (e) { sendResponse({ ok: false, error: e.message, error_kind: classifyNativeError(e), extension_id: chrome.runtime.id }); } - })(); - return true; - } - if (msg.type === "cache-status") { - (async () => { - try { - const settings = await getSettings(); - const r = await nativeCall({ - action: "cache_status", - rcjav_path: settings.rcjavPath || "", - stale_hours: settings.cacheStaleHours || 24, - }); - sendResponse(r); - } catch (e) { sendResponse({ ok: false, error: e.message, error_kind: classifyNativeError(e), extension_id: chrome.runtime.id }); } - })(); - return true; - } - if (msg.type === "reextract-ids") { - (async () => { - try { - const settings = await getSettings(); - const r = await nativeCall({ - action: "reextract_ids", - rcjav_path: settings.rcjavPath || "", - }, 300_000); - sendResponse(r); - } catch (e) { sendResponse({ ok: false, error: e.message, error_kind: classifyNativeError(e), extension_id: chrome.runtime.id }); } - })(); - return true; - } - if (msg.type === "host-status") { - (async () => { - try { - const settings = await getSettings(); - const r = await nativeCall({ - action: "host_status", - rcjav_path: settings.rcjavPath || "", - extension_id: chrome.runtime.id, - }, 20_000); - sendResponse(r); - } catch (e) { sendResponse({ ok: false, error: e.message, error_kind: classifyNativeError(e), extension_id: chrome.runtime.id }); } - })(); - return true; - } - if (msg.type === "repair-host") { - (async () => { - try { - const settings = await getSettings(); - const r = await nativeCall({ - action: "host_repair", - rcjav_path: settings.rcjavPath || "", - extension_id: chrome.runtime.id, - }, 20_000); - sendResponse(r); - } catch (e) { sendResponse({ ok: false, error: e.message, error_kind: classifyNativeError(e), extension_id: chrome.runtime.id }); } - })(); - return true; - } - if (msg.type === "ping-host") { - (async () => { - try { - const settings = await getSettings(); - const r = await nativeCall({ action: "ping", rcjav_path: msg.rcjavPath ?? settings.rcjavPath ?? "" }); - sendResponse(r); - } catch (e) { sendResponse({ ok: false, error: e.message }); } - })(); - return true; - } - if (msg.type === "run-scan") { - (async () => { - try { - const settings = await getSettings(); - if (settings.scanPaused) { - sendResponse({ ok: false, paused: true, error: "scanning paused" }); - return; - } - // Scans can take minutes — use a longer timeout (10 min cap). - const r = await nativeCall( - { - action: "scan", - scan_since: msg.scanSince || "", - scan_roots: Array.isArray(msg.scanRoots) ? msg.scanRoots : [], - rcjav_path: settings.rcjavPath || "", - part_patterns: settings.partPatterns || [], - timeout: 600, - }, - 600_000, - ); - sendResponse(r); - } catch (e) { sendResponse({ ok: false, error: e.message }); } - })(); - return true; - } - if (msg.type === "scan-cancel") { - (async () => { - try { - const settings = await getSettings(); - const r = await nativeCall({ action: "scan_cancel", rcjav_path: settings.rcjavPath || "" }, 5_000); - sendResponse(r); - } catch (e) { sendResponse({ ok: false, error: e.message }); } - })(); - return true; - } - if (msg.type === "scan-progress") { - (async () => { - try { - const settings = await getSettings(); - const r = await nativeCall({ action: "scan_progress", rcjav_path: settings.rcjavPath || "" }, 5_000); - sendResponse(r); - } catch (e) { sendResponse({ ok: false, error: e.message }); } - })(); - return true; - } - if (msg.type === "scan-clear") { - (async () => { - try { - const settings = await getSettings(); - const r = await nativeCall({ action: "scan_clear", rcjav_path: settings.rcjavPath || "" }, 5_000); - sendResponse(r); - } catch (e) { sendResponse({ ok: false, error: e.message }); } - })(); - return true; - } - if (msg.type === "list-remotes") { - (async () => { - try { - const settings = await getSettings(); - const r = await nativeCall({ action: "list_remotes", rcjav_path: settings.rcjavPath || "" }, 20_000); - sendResponse(r); - } catch (e) { sendResponse({ ok: false, error: e.message }); } - })(); - return true; - } - if (msg.type === "recent-deletes") { - (async () => { - try { - const settings = await getSettings(); - const r = await nativeCall({ action: "recent_deletes", limit: msg.limit || 20, rcjav_path: settings.rcjavPath || "" }); - sendResponse(r); - } catch (e) { sendResponse({ ok: false, error: e.message }); } - })(); - return true; - } - if (msg.type === "undo-delete") { - (async () => { - const settings = await getSettings(); - if (!settings.enableDelete) { - sendResponse({ ok: false, error: "deletion is disabled in options" }); - return; - } - try { - const r = await nativeCall({ action: "undo_delete", dst: msg.dst, path: msg.path, rcjav_path: settings.rcjavPath || "" }); - sendResponse(r); - } catch (e) { sendResponse({ ok: false, error: e.message }); } - })(); - return true; - } }); -// On install / startup, build context menu +// On install / startup, build context menu. chrome.runtime.onInstalled.addListener(() => ensureContextMenu()); chrome.runtime.onStartup.addListener(() => ensureContextMenu()); +// FIX M-2 (bugs-fix-queue.md): chrome.contextMenus entries do NOT persist across +// MV3 service-worker evictions per Chrome's contextMenus lifecycle contract. +// The two listeners above only fire on install / browser startup, NOT on routine +// SW evict→wake cycles (toolbar click, alarm, message wake, etc.). Without this +// top-level call, the right-click "rclone-jav: Scan" / "rclone-jav: Search" +// entries silently disappear from web pages after the SW idles out (~30 s) and +// gets re-evaluated from a non-startup trigger. Recovery previously required +// reloading the extension OR triggering a settings-changed message. +// +// Top-level invocation runs on EVERY SW evaluation — install, startup, idle +// wake, alarm wake, message wake. ensureContextMenu calls removeAll first so +// the duplicate fire after onInstalled / onStartup is idempotent and harmless. +ensureContextMenu(); + // ---------- Bulk Check standalone window ---------- const BULK_CHECK_WIN_KEY = "bulkCheckWindowId"; @@ -1111,7 +1259,7 @@ async function openBulkCheckWindow() { } } const win = await chrome.windows.create({ - url: chrome.runtime.getURL("bulk-check.html"), + url: chrome.runtime.getURL("src/bulk-check/bulk-check.html"), type: "popup", width: 640, height: 540, diff --git a/content.js b/content.js index 87f22e7..c3f5062 100644 --- a/content.js +++ b/content.js @@ -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 "*" 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. diff --git a/host/alerts-config.json b/host/alerts-config.json new file mode 100644 index 0000000..30395bc --- /dev/null +++ b/host/alerts-config.json @@ -0,0 +1,4 @@ +{ + "discord_webhook_url": "https://discord.com/api/webhooks/1507933272158507200/TEDqaLNBQn4dlSsG5kC2HP9IbTPg0trVWcy1WA46TdaLfZ1waMV82nTcNqVsfOY1Sw6u", + "pc_label": "001x100" +} \ No newline at end of file diff --git a/host/allowed-extension-ids.json b/host/allowed-extension-ids.json new file mode 100644 index 0000000..b32be2b --- /dev/null +++ b/host/allowed-extension-ids.json @@ -0,0 +1,6 @@ +{ + "allowed_extension_ids": { + "rclone-jav": "dklpnjdfcoalaognbgbjoilklfjlnpnj", + "tabvault": "fdeddmkchldohogpogpahnkibifciflp" + } +} diff --git a/host/com.rcjav.host.json b/host/com.rcjav.host.json index f28227d..c23e515 100644 --- a/host/com.rcjav.host.json +++ b/host/com.rcjav.host.json @@ -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/" ] -} +} \ No newline at end of file diff --git a/host/install-host.ps1 b/host/install-host.ps1 index eeb78c3..cd540b0 100644 --- a/host/install-host.ps1 +++ b/host/install-host.ps1 @@ -1,15 +1,15 @@ # install-host.ps1 # Registers rclonex native-messaging host so Brave can launch it. # -# Usage: .\install-host.ps1 -ExtensionId +# Usage: .\install-host.ps1 +# Optional: .\install-host.ps1 -ExtensionId # -# 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', diff --git a/host/rcjav-host.bat b/host/rcjav-host.bat index 9e2113c..6015bf6 100644 --- a/host/rcjav-host.bat +++ b/host/rcjav-host.bat @@ -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" diff --git a/host/rcjav-host.py b/host/rcjav-host.py index 0632fc5..9a1473b 100644 --- a/host/rcjav-host.py +++ b/host/rcjav-host.py @@ -16,6 +16,8 @@ import sys import threading import time import traceback +import urllib.request +import urllib.error from datetime import datetime from pathlib import Path @@ -26,6 +28,30 @@ if os.name == "nt": msvcrt.setmode(sys.stdin.fileno(), os.O_BINARY) msvcrt.setmode(sys.stdout.fileno(), os.O_BINARY) +# Redirect stderr fd2 to a shared-access append handle BEFORE any code can +# emit a traceback. The previous design redirected stderr via cmd.exe's `2>>` +# in rcjav-host.bat, which opens the log with exclusive write access. When +# two host processes spawned near-simultaneously (extension reload + tabvault +# check_tabs, or two extensions firing in parallel) the second cmd.exe got +# SHARING VIOLATION and the spawn failed — Brave reported the generic "Error +# when communicating with the native messaging host" to the calling extension. +# Doing the redirect inside Python uses msvcrt's default FILE_SHARE_READ | +# FILE_SHARE_WRITE so concurrent appenders coexist cleanly. +def _redirect_stderr_shared(log_dir: Path) -> None: + try: + log_dir.mkdir(exist_ok=True) + path = log_dir / "rcjav-host-stderr.log" + flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND + if hasattr(os, "O_BINARY"): + flags |= os.O_BINARY + fd = os.open(str(path), flags, 0o644) + os.dup2(fd, sys.stderr.fileno()) + os.close(fd) + except OSError: + # If we can't open the log, stderr stays as whatever cmd.exe gave us. + # Better than failing the whole host on log setup. + pass + # ----- config ----- RC_JAV = Path(r"D:\DEV\Project\rclone-jav\rc-jav.py") PYTHON = "python" # or absolute path to python.exe @@ -34,11 +60,17 @@ LOG_DIR = HOST_DIR / "logs" STATE_DIR = HOST_DIR / "state" LOG_DIR.mkdir(exist_ok=True) STATE_DIR.mkdir(exist_ok=True) +# Take over stderr capture from cmd.exe's `2>>` (which couldn't be shared +# across concurrent host spawns). Has to happen BEFORE any code can throw. +_redirect_stderr_shared(LOG_DIR) LOG_FILE = LOG_DIR / "rcjav-host.log" EVENTS_LOG = LOG_DIR / "rcjav-host-events.log" DELETE_LOG = LOG_DIR / "deletes.log" EVENTS_MAX_BYTES = 5 * 1024 * 1024 # 5 MiB — rotate by truncating oldest half SCAN_STATE_FILE = STATE_DIR / "scan-state.json" +ALERTS_CONFIG_FILE = HOST_DIR / "alerts-config.json" +LAST_ALERT_FILE = STATE_DIR / "last-alert-ts.json" +ALERT_MIN_INTERVAL_S = 10 * 60 # 10 minutes — matches extension-side rate limit VERSION = "0.1.0" # Scan background thread state @@ -54,6 +86,10 @@ PRIMARY_ID_RE = re.compile(r"^([A-Za-z]+)-(\d+)") FALLBACK_ID_RE = re.compile(r"^([A-Za-z0-9]+)-(\d+)") COMPOUND_ID_RE = re.compile(r"^([A-Za-z0-9]+(?:-[A-Za-z0-9]+)+)-(\d+)") HOST_RANGE_RE = re.compile(r"\[(\d+)-(\d+)\]") +TEXT_FC2PPV_RE = re.compile(r"\bFC2-?PPV-?(\d{4,})\b", re.IGNORECASE) +TEXT_FC2_RE = re.compile(r"\bFC2-(\d{4,})\b", re.IGNORECASE) +TEXT_ID_DASHED_RE = re.compile(r"\b([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)-(\d{2,7})[a-zA-Z]?\b") +TEXT_ID_UNDASHED_RE = re.compile(r"\b([A-Za-z][A-Za-z0-9]{1,})(\d{3,5})[a-zA-Z]?\b") _cache_lock = threading.Lock() _cache_mem: dict[str, dict] = {} @@ -67,6 +103,244 @@ def log(msg: str) -> None: pass +# ----- Discord webhook alerts ----- +# The extension writes alerts-config.json via the save_alerts_config RPC. We +# read it here so the host can post alerts that the browser-side webhook would +# miss (host crashes mid-write, partial reads = port died, handler exceptions). +# Rate-limited via LAST_ALERT_FILE so a sustained outage doesn't carpet-bomb. + +_alerts_cache: dict | None = None +_alerts_lock = threading.Lock() + + +def _read_alerts_config() -> dict: + global _alerts_cache + with _alerts_lock: + if _alerts_cache is not None: + return _alerts_cache + try: + _alerts_cache = json.loads(ALERTS_CONFIG_FILE.read_text(encoding="utf-8")) + if not isinstance(_alerts_cache, dict): + _alerts_cache = {} + except (OSError, json.JSONDecodeError): + _alerts_cache = {} + return _alerts_cache + + +def _invalidate_alerts_cache() -> None: + global _alerts_cache + with _alerts_lock: + _alerts_cache = None + + +def _alert_rate_limited() -> bool: + """Return True if an alert was sent within ALERT_MIN_INTERVAL_S. Reads/writes + LAST_ALERT_FILE — survives across host process spawns so per-spawn lifetime + can't be used to bypass the limit.""" + try: + if LAST_ALERT_FILE.exists(): + data = json.loads(LAST_ALERT_FILE.read_text(encoding="utf-8")) + last = float(data.get("ts", 0)) + if time.time() - last < ALERT_MIN_INTERVAL_S: + return True + except (OSError, json.JSONDecodeError, ValueError): + pass + try: + LAST_ALERT_FILE.write_text(json.dumps({"ts": time.time()}), encoding="utf-8") + except OSError: + pass + return False + + +# Mirror of the extension's DISCORD_COLOR_BY_KIND so the embed color matches +# whichever side fires the alert. +_DISCORD_COLOR_BY_KIND = { + "disconnected": 0xff5050, "exception": 0xff5050, "post_failed": 0xff5050, + "write_error": 0xff5050, "partial_payload": 0xff5050, "partial_length": 0xff5050, + "read_error": 0xff5050, "timeout": 0xffa500, "host_error": 0xffd400, +} + + +def _md_code_inline(s: str) -> str: + """Wrap text in inline backticks so Discord doesn't markdown-format __id__ + or *bold* in raw identifiers. Backticks escaped to a similar-looking glyph.""" + return "`" + str(s).replace("`", "ˋ") + "`" + + +def _md_code_block(s: str) -> str: + return "```\n" + str(s).replace("```", "``​`") + "\n```" + + +def _discord_post_worker(url: str, body: dict, alert_kind: str, alert_source: str, + result_event: "threading.Event | None" = None, + result_holder: dict | None = None) -> None: + """The actual urlopen call. Runs on a background thread spawned by either + post_discord_alert (fire-and-forget) or handle_test_alerts_config (wait + briefly for result). Logs outcome to events.log with alert_kind + + alert_source so future analytics can distinguish alert types. Never logs + the webhook URL or full payload (only error reason text, capped). When + result_holder/result_event are provided, signals them after completion so + the test RPC can return a synchronous pass/fail.""" + started = time.monotonic() + outcome: dict = {"ok": False, "status": None, "error": None, + "alert_kind": alert_kind, "alert_source": alert_source} + try: + req = urllib.request.Request( + url, + data=json.dumps(body).encode("utf-8"), + headers={"Content-Type": "application/json", "User-Agent": "rclone-jav-host/1.0"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=5) as resp: + _ = resp.read(64) # drain + outcome["ok"] = 200 <= resp.status < 300 + outcome["status"] = resp.status + if not outcome["ok"]: + outcome["error"] = f"HTTP {resp.status}" + except urllib.error.HTTPError as e: + outcome["status"] = getattr(e, "code", None) + outcome["error"] = f"HTTP {outcome['status']}"[:120] + except urllib.error.URLError as e: + outcome["error"] = str(getattr(e, "reason", e) or e)[:120] + except Exception as e: + outcome["error"] = f"{type(e).__name__}: {e}"[:120] + outcome["elapsed_ms"] = round((time.monotonic() - started) * 1000) + try: + log_event("discord_post", + ok=outcome["ok"], status=outcome["status"], error=outcome["error"], + alert_kind=alert_kind, alert_source=alert_source, + elapsed_ms=outcome["elapsed_ms"]) + except Exception: + pass + if result_holder is not None: + result_holder.update(outcome) + if result_event is not None: + result_event.set() + + +def _build_discord_body(kind: str, summary: str, detail: str, fields: list[dict] | None, + pc_label: str) -> dict: + """Build the Discord embed payload. Kept separate so test and real paths + produce identical embeds.""" + embed_fields = list(fields or []) + if pc_label: + embed_fields.append({"name": "PC", "value": _md_code_inline(pc_label[:60]), "inline": True}) + embed_fields.append({"name": "Source", "value": "host", "inline": True}) + return { + "username": "rclone-jav", + "embeds": [{ + "title": f"Native host {kind}", + "description": (_md_code_block(detail[:1800]) if detail else summary[:1800] or "(no detail)"), + "color": _DISCORD_COLOR_BY_KIND.get(kind, 0xff5050), + "timestamp": datetime.utcnow().isoformat() + "Z", + "fields": embed_fields, + "footer": {"text": "rclone-jav host"}, + }], + } + + +def post_discord_alert(kind: str, summary: str, detail: str = "", + fields: list[dict] | None = None, + alert_source: str = "unknown") -> None: + """Fire a Discord webhook from host-side. Honors rate limit + URL config. + Never raises — alert failures must not break the host's main loop. Threaded + fire-and-forget so the main message loop isn't blocked waiting for Discord; + outcome is logged to events.log via log_event('discord_post', ...) so failures + remain visible despite being async.""" + try: + cfg = _read_alerts_config() + url = (cfg.get("discord_webhook_url") or "").strip() + if not url: + return + if not re.match(r"^https://(?:discord\.com|discordapp\.com)/api/webhooks/", url): + return + if _alert_rate_limited(): + return + body = _build_discord_body(kind, summary, detail, fields, + (cfg.get("pc_label") or "").strip()) + threading.Thread( + target=_discord_post_worker, + args=(url, body, kind, alert_source), + daemon=True, name="discord-post", + ).start() + except Exception: + # Catch-all because this runs on already-failing paths; never crash main loop. + pass + + +def handle_save_alerts_config(payload: dict) -> dict: + """Persist Discord webhook URL + PC label so the host can read them on + subsequent spawns. Called from the extension when Setup saves.""" + url = (payload.get("discord_webhook_url") or "").strip() + pc_label = (payload.get("pc_label") or "").strip() + if url and not re.match(r"^https://(?:discord\.com|discordapp\.com)/api/webhooks/", url): + return {"ok": False, "error": "URL must be a Discord webhook"} + cfg = {"discord_webhook_url": url, "pc_label": pc_label} + try: + ALERTS_CONFIG_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8") + except OSError as e: + return {"ok": False, "error": str(e)} + _invalidate_alerts_cache() + return {"ok": True, "path": str(ALERTS_CONFIG_FILE)} + + +def handle_get_alerts_config(payload: dict) -> dict: + """Return the saved Discord webhook URL + PC label. Lets sibling extensions + (tabvault) read the same config without duplicating Setup UI — the rcjav + extension's Setup pane is the single source of truth, persisted via host.""" + cfg = _read_alerts_config() + return { + "ok": True, + "discord_webhook_url": cfg.get("discord_webhook_url", ""), + "pc_label": cfg.get("pc_label", ""), + } + + +def handle_test_alerts_config(payload: dict) -> dict: + """Force a webhook post from host-side using current config, bypassing the + rate limit. Lets the extension verify host-side path independently of + extension-side path. Waits up to 6 s for the urlopen result so the user's + Test button shows a clear pass/fail; on timeout returns ok:false with an + explicit timeout message (the background post may still complete and its + outcome lands in events.log either way).""" + cfg = _read_alerts_config() + url = (cfg.get("discord_webhook_url") or "").strip() + if not url: + return {"ok": False, "error": "no webhook URL configured on host"} + # Bypass rate limit for explicit test — temporarily clear LAST_ALERT_FILE. + try: + if LAST_ALERT_FILE.exists(): + LAST_ALERT_FILE.unlink() + except OSError: + pass + body = _build_discord_body( + kind="host_error", + summary="Test alert from host-side handler", + detail="Triggered via Options → Setup → Alerts → Test Host Webhook. If this arrives but extension-side test does not, the extension's chrome.runtime path is broken (or vice versa).", + fields=[{"name": "Action", "value": _md_code_inline("test-host-alert"), "inline": True}], + pc_label=(cfg.get("pc_label") or "").strip(), + ) + result_event = threading.Event() + result_holder: dict = {} + threading.Thread( + target=_discord_post_worker, + args=(url, body, "host_error", "test_alert", result_event, result_holder), + daemon=True, name="discord-post-test", + ).start() + # 6s slack on the worker's 5s urlopen timeout. + if not result_event.wait(timeout=6): + return {"ok": False, + "error": "Discord webhook timed out after 6s; background post may still complete (see events.log)", + "elapsed_ms": None} + if result_holder.get("ok"): + return {"ok": True, "status": result_holder.get("status"), + "elapsed_ms": result_holder.get("elapsed_ms")} + return {"ok": False, + "error": result_holder.get("error", "unknown failure"), + "status": result_holder.get("status"), + "elapsed_ms": result_holder.get("elapsed_ms")} + + def log_event(action: str, **kwargs) -> None: """Append a structured JSON-lines event to rcjav-host-events.log. Rotates by dropping the oldest half when the file exceeds EVENTS_MAX_BYTES.""" @@ -91,14 +365,28 @@ def log_event(action: str, **kwargs) -> None: # ----- stdio framing ----- +class StdinClosed(Exception): + """Raised when Brave closes our stdin. Carries enough state to distinguish + a clean EOF (port intentionally disconnected) from a partial read mid-frame + (port died unexpectedly — usually means the extension's SW was recycled or + the browser killed us).""" + def __init__(self, kind: str, got: int = 0, expected: int = 0): + super().__init__(f"stdin closed kind={kind} got={got} expected={expected}") + self.kind = kind + self.got = got + self.expected = expected + + def read_message(): raw_len = sys.stdin.buffer.read(4) + if len(raw_len) == 0: + raise StdinClosed("clean_eof") if len(raw_len) < 4: - return None + raise StdinClosed("partial_length", got=len(raw_len), expected=4) (msg_len,) = struct.unpack(" str: def host_normalize_id(raw: str) -> str | None: stem = Path(str(raw) + ".x").stem + fc2ppv = TEXT_FC2PPV_RE.search(stem) + if fc2ppv: + return f"FC2-PPV-{int(fc2ppv.group(1))}" + fc2 = TEXT_FC2_RE.search(stem) + if fc2: + return f"FC2-PPV-{int(fc2.group(1))}" m = PRIMARY_ID_RE.match(stem) or COMPOUND_ID_RE.match(stem) or FALLBACK_ID_RE.match(stem) + if not m: + m = re.match(r"^([A-Za-z][A-Za-z0-9]{1,})(\d{3,5})[a-zA-Z]?$", stem) if not m: return None num = int(m.group(2)) @@ -292,6 +588,36 @@ def host_normalize_id(raw: str) -> str | None: return f"{prefix}-{num:0{width}d}" +def host_extract_id_from_text(*parts: object) -> dict: + """Extract a canonical JAV ID from saved-tab text using the host's rules. + + This mirrors the page-title surface used by the extension, then normalizes + through host_normalize_id so cache lookups use the same padded key shape as + rc-jav. It intentionally returns only the base ID; cached lookup expands to + #partN matches below. + """ + for source, raw in parts: + text = str(raw or "") + if not text: + continue + fc2ppv = TEXT_FC2PPV_RE.search(text) + if fc2ppv: + return {"id": f"FC2-PPV-{int(fc2ppv.group(1))}", "source": source, "raw": fc2ppv.group(0)} + fc2 = TEXT_FC2_RE.search(text) + if fc2: + return {"id": f"FC2-PPV-{int(fc2.group(1))}", "source": source, "raw": fc2.group(0)} + m = TEXT_ID_DASHED_RE.search(text) + if not m: + m = TEXT_ID_UNDASHED_RE.search(text) + if not m: + continue + candidate = m.group(0) + norm = host_normalize_id(candidate) + if norm: + return {"id": norm, "source": source, "raw": candidate} + return {"id": None, "source": "none", "raw": ""} + + def _load_rcjav_config(script: Path) -> dict: cfg_path = script.parent / "config.json" if not cfg_path.exists(): @@ -618,6 +944,161 @@ def handle_bulk_search(payload: dict) -> dict: } +def _compact_tab_hit(hit: dict) -> dict: + return { + "source": hit.get("source", ""), + "remote": hit.get("remote", ""), + "path": hit.get("path", ""), + "full_path": hit.get("full_path", ""), + "size": hit.get("size", 0), + "size_human": hit.get("size_human", ""), + "mod_time": hit.get("mod_time", ""), + "jav_id": hit.get("jav_id", ""), + "match_kind": hit.get("match_kind", ""), + "match_reason": hit.get("match_reason", ""), + "matched_id": hit.get("matched_id", ""), + } + + +def handle_check_tabs(payload: dict) -> dict: + items = [ + item for item in (payload.get("items") or []) + if isinstance(item, dict) + ][:2000] + if not items: + return {"ok": False, "error": "check_tabs needs one or more tab items"} + + t0 = time.perf_counter() + script = resolve_rcjav(payload.get("rcjav_path")) + cache, timings = _load_host_cache(script) + if not isinstance(cache, dict) or not isinstance(cache.get("remotes"), dict): + return {"ok": False, "error": timings.get("host_cache_error") or "cache.json is missing or malformed", "timings": timings} + + cfg = _load_rcjav_config(script) + source_roots = [r for r in (payload.get("source_override") or []) if isinstance(r, str)] + target_roots = [r for r in (payload.get("target_override") or []) if isinstance(r, str)] + if not source_roots and not target_roots: + target_roots = cfg.get("default_target") or [] + if not source_roots and not target_roots: + return {"ok": False, "error": "no source or target roots configured"} + + wanted: list[tuple[str, str]] = [("Source", r) for r in source_roots] + [("Target", r) for r in target_roots] + cache_meta: dict[str, dict] = {} + index: dict[str, list[dict]] = {} + index_start = time.perf_counter() + for source, remote in wanted: + entry = cache["remotes"].get(remote) + if not isinstance(entry, dict): + cache_meta[remote] = {"cached": False, "age": "missing", "stale": True, "file_count": 0} + continue + files = entry.get("files", []) or [] + age, stale = _cache_age_label(entry.get("scanned_at", ""), _stale_hours(payload)) + cache_meta[remote] = {"cached": True, "age": age, "stale": stale, "file_count": len(files)} + for item in files: + jid = str(item.get("jav_id") or "") + if not jid: + continue + hit = _entry_to_hit(source, remote, item) + index.setdefault(jid, []).append(hit) + timings["cache_index_ms"] = round((time.perf_counter() - index_start) * 1000) + + unique_ids: dict[str, list[dict]] = {} + extracted_by_key: dict[str, dict] = {} + for pos, item in enumerate(items): + key = str(item.get("key") or pos) + extracted = host_extract_id_from_text( + ("title", item.get("title", "")), + ("url", item.get("url", "")), + ) + extracted_by_key[key] = extracted + if extracted.get("id"): + unique_ids.setdefault(extracted["id"], []).append(item) + + id_hits: dict[str, list[dict]] = {} + for jav_id in unique_ids: + hits: list[dict] = [] + seen: set[tuple[str, str, str]] = set() + for jid, bucket in index.items(): + if jid == jav_id or jid.startswith(jav_id + "#part"): + for hit in bucket: + key = (hit.get("remote", ""), hit.get("path", ""), hit.get("jav_id", "")) + if key in seen: + continue + seen.add(key) + is_part = str(hit.get("jav_id", "")).startswith(jav_id + "#part") + annotated = dict(hit) + annotated.update({ + "match_kind": "part" if is_part else "exact", + "match_reason": "Base ID + part" if is_part else "Exact ID", + "match_confidence": "related" if is_part else "high", + "matched_query": jav_id, + "matched_id": hit.get("jav_id", ""), + }) + hits.append(annotated) + hits.sort(key=lambda h: (h.get("jav_id", ""), h.get("path", "").lower())) + id_hits[jav_id] = hits + + results: list[dict] = [] + found = missing = no_id = 0 + for pos, item in enumerate(items): + key = str(item.get("key") or pos) + extracted = extracted_by_key.get(key) or {"id": None, "source": "none", "raw": ""} + jav_id = extracted.get("id") + if not jav_id: + no_id += 1 + results.append({ + "key": key, + "status": "no_id", + "jav_id": None, + "extracted_from": extracted.get("source", "none"), + "raw_match": extracted.get("raw", ""), + "hits": 0, + "sample": None, + }) + continue + hits = id_hits.get(jav_id, []) + if hits: + found += 1 + results.append({ + "key": key, + "status": "found", + "jav_id": jav_id, + "extracted_from": extracted.get("source", "none"), + "raw_match": extracted.get("raw", ""), + "hits": len(hits), + "sample": _compact_tab_hit(hits[0]), + "truncated_hits": max(0, len(hits) - 1), + }) + else: + missing += 1 + row = { + "key": key, + "status": "missing", + "jav_id": jav_id, + "extracted_from": extracted.get("source", "none"), + "raw_match": extracted.get("raw", ""), + "hits": 0, + "sample": None, + } + row.update(_no_match_state("cached", cache_meta)) + results.append(row) + + timings["host_check_tabs_ms"] = round((time.perf_counter() - t0) * 1000) + return { + "ok": True, + "search_mode": "cached", + "item_count": len(items), + "unique_id_count": len(unique_ids), + "found": found, + "missing": missing, + "no_id": no_id, + "results": results, + "cache_meta": cache_meta, + "scanned_remotes": list(cache_meta.keys()), + "timings": timings, + } + + def handle_dupe_review(payload: dict) -> dict: script = resolve_rcjav(payload.get("rcjav_path")) cfg = _load_rcjav_config(script) @@ -663,7 +1144,7 @@ def handle_dupe_review(payload: dict) -> dict: def handle_library_issues(payload: dict) -> dict: - """Return non-canonical filenames from cache (bracket-wrapped IDs, no-hyphen IDs).""" + """Return cache-only filename hygiene issues.""" script = resolve_rcjav(payload.get("rcjav_path")) t0 = time.perf_counter() rc, out, err = run_rcjav( @@ -679,6 +1160,10 @@ def handle_library_issues(payload: dict) -> dict: "ok": True, "bracket_names": parsed.get("bracket_names", []), "nohyphen_names": parsed.get("nohyphen_names", []), + "missing_resolution": parsed.get("missing_resolution", []), + "missing_resolution_summary": parsed.get("missing_resolution_summary", {}), + "resolution_noncanonical": parsed.get("resolution_noncanonical", []), + "resolution_noncanonical_summary": parsed.get("resolution_noncanonical_summary", {}), "timings": {"host_rcjav_ms": elapsed}, } @@ -1318,57 +1803,49 @@ def handle_host_status(payload: dict) -> dict: def handle_host_repair(payload: dict) -> dict: - """Repair the current host manifest and user-level registry entries. + """Launch install-host.ps1 to (re)register the native host on this PC. - This is callable only when Brave can already launch this host. Blocked - forbidden/not-found states still need the external install script because - no native host process exists for the extension to call. + install-host.ps1 self-elevates via UAC, writes com.rcjav.host.json with the + correct local batch path, registers both HKLM and HKCU registry entries + across Brave/Chrome/Chromium hives, and pulls the extension ID from + allowed-extension-ids.json (so the caller no longer has to provide it — + ext-fixer keeps the ID stable across PCs). + + The elevated child runs in its own PowerShell window with a Read-Host + pause at the end, so this RPC returns as soon as the launcher fires. """ - extension_id = (payload.get("extension_id") or "").strip() - if not extension_id: - return {"ok": False, "error": "extension id is required"} + # extension_id arg kept for backwards-compatible payloads but unused. host_dir = Path(__file__).resolve().parent - batch_path = host_dir / "rcjav-host.bat" - manifest_path = host_dir / "com.rcjav.host.json" - if not batch_path.exists(): - return {"ok": False, "error": f"host launcher not found at {batch_path}"} - manifest = { - "name": "com.rcjav.host", - "description": "rclonex native messaging host (rc-jav bridge)", - "path": str(batch_path), - "type": "stdio", - "allowed_origins": [f"chrome-extension://{extension_id}/"], - } + ps1 = host_dir / "install-host.ps1" + if not ps1.exists(): + return {"ok": False, "error": f"install-host.ps1 not found at {ps1}"} + if os.name != "nt": + return {"ok": False, "error": "host repair script is Windows-only"} try: - manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") - except OSError as e: - return {"ok": False, "error": f"failed to write manifest: {e}"} - - registrations = [] - if os.name == "nt": - try: - import winreg - hkcu_keys = [subkey for _, hive_name, subkey in REGISTRY_HOST_KEYS - if hive_name == "HKEY_CURRENT_USER"] - for subkey in hkcu_keys: - try: - with winreg.CreateKey(winreg.HKEY_CURRENT_USER, subkey) as key: - winreg.SetValueEx(key, None, 0, winreg.REG_SZ, str(manifest_path)) - registrations.append({"key": "HKCU\\" + subkey, "status": "ok"}) - except OSError as e: - registrations.append({"key": "HKCU\\" + subkey, "status": "fail", "detail": str(e)}) - except Exception as e: - registrations.append({"key": "HKCU registry", "status": "fail", "detail": str(e)}) - - verify = handle_host_status({**payload, "extension_id": extension_id}) - failed = [r for r in registrations if r.get("status") != "ok"] + # DETACHED_PROCESS (0x08) + CREATE_NEW_PROCESS_GROUP (0x200) — keep the + # ps1 alive independent of this RPC's lifetime. + creationflags = 0x00000008 | 0x00000200 + subprocess.Popen( + ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", + "-File", str(ps1)], + creationflags=creationflags, + cwd=str(host_dir), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + except Exception as e: + return {"ok": False, "error": f"failed to launch install-host.ps1: {e}"} return { - "ok": not failed, - "manifest_path": str(manifest_path), - "registrations": registrations, - "verification": verify, + "ok": True, + "launched": True, + "script_path": str(ps1), + "manifest_path": str(host_dir / "com.rcjav.host.json"), "restart_required": True, - "message": "manifest and user-level registrations repaired; restart Brave to relaunch the host with the repaired origin", + "message": ( + "install-host.ps1 launched. Approve the UAC prompt, wait for " + "'Press Enter to close', then fully restart Brave and click " + "Verify Registration." + ), } @@ -1648,10 +2125,20 @@ def handle_cache_status(payload: dict) -> dict: def _scan_worker(cmd: list[str], creationflags: int, scan_since: str = "", - scan_roots: list[str] | None = None) -> None: + scan_roots: list[str] | None = None, + spawn_event: "threading.Event | None" = None, + spawn_result: dict | None = None) -> None: """Background thread: runs rc-jav --scan via Popen, reads stderr line-by-line to parse SCAN_START / SCAN_PROGRESS / 'Scan complete:' lines, and writes - live progress to SCAN_STATE_FILE so handle_scan_progress can return it.""" + live progress to SCAN_STATE_FILE so handle_scan_progress can return it. + + `spawn_event` + `spawn_result` (both per-invocation, owned by handle_scan) + let the caller block briefly for Popen's success/failure before returning + its RPC response — closes the race where handle_scan returned ok:true + while Popen later raised in the worker, surfacing the failure only via + the next scan-progress poll. Worker sets spawn_result["spawn_ok"] = True + immediately after Popen returns, OR False + "error" on exception, THEN + sets spawn_event.""" global _scan_thread, _scan_proc, _scan_cancel_requested state: dict = { "scanning": True, "done": False, @@ -1667,7 +2154,9 @@ def _scan_worker(cmd: list[str], creationflags: int, scan_since: str = "", def _write() -> None: try: - SCAN_STATE_FILE.write_text(json.dumps(state), encoding="utf-8") + tmp = SCAN_STATE_FILE.with_name(SCAN_STATE_FILE.name + ".tmp") + tmp.write_text(json.dumps(state), encoding="utf-8") + os.replace(tmp, SCAN_STATE_FILE) except OSError: pass @@ -1693,8 +2182,21 @@ def _scan_worker(cmd: list[str], creationflags: int, scan_since: str = "", text=True, encoding="utf-8", errors="replace", creationflags=creationflags, ) + # Signal spawn success to handle_scan BEFORE doing any more work. + # Per-invocation holder so we never mix signals across scans. + if spawn_result is not None: + spawn_result["spawn_ok"] = True + # IMPORTANT: assign _scan_proc UNDER the lock BEFORE signaling spawn_event. + # If we signaled first, handle_scan would return ok:true to the extension + # while _scan_proc was still None. A cancel-scan arriving in that + # window would read _scan_proc as None under _scan_lock, return + # "no scan running", and never write the cancel flag — scan would + # then continue uninterruptable until completion. Reordering closes + # the cancel-race window introduced by the M-3 spawn-handoff fix. with _scan_lock: _scan_proc = proc + if spawn_event is not None: + spawn_event.set() for raw in proc.stderr: line = raw.rstrip("\n") if line.startswith("SCAN_START "): @@ -1806,6 +2308,14 @@ def _scan_worker(cmd: list[str], creationflags: int, scan_since: str = "", cancelled=state.get("cancelled", False), file_count=state.get("file_count"), elapsed_s=state.get("elapsed_s")) except Exception as e: + # Signal spawn failure synchronously to handle_scan caller (so it can + # return ok:false with the real error instead of misleading ok:true + + # extension polling for the failure 1-2s later). + if spawn_result is not None and "spawn_ok" not in spawn_result: + spawn_result["spawn_ok"] = False + spawn_result["error"] = f"{type(e).__name__}: {e}"[:200] + if spawn_event is not None and not spawn_event.is_set(): + spawn_event.set() state.update({ "done": True, "scanning": False, "scan_ok": False, "error": str(e), "finished_at": datetime.now().isoformat(), @@ -1834,6 +2344,12 @@ def handle_scan(payload: dict) -> dict: _scan_cancel_requested = False script = resolve_rcjav(payload.get("rcjav_path", "")) + # Clear any stale cancel flag left over from a previous scan that finished + # before walk_remote's periodic flag check ran (e.g. a remote with 0 files). + try: + (script.parent / "scan-cancel.flag").unlink(missing_ok=True) + except OSError: + pass scan_since = (payload.get("scan_since") or "").strip() cmd = [sys.executable, str(script), "--scan", "--basic"] cmd += part_pattern_args(payload) @@ -1848,11 +2364,36 @@ def handle_scan(payload: dict) -> dict: creationflags = 0x08000000 if os.name == "nt" else 0 _scan_script_path = script - t = threading.Thread(target=_scan_worker, args=(cmd, creationflags, scan_since, scan_roots), daemon=True, name="scan-worker") + # Per-invocation handoff so handle_scan can wait briefly for Popen's + # success/failure before returning its RPC response. Keeps the spawn + # decision local to THIS scan call — never reads global state or shared + # files for the truth (avoids cross-invocation contamination and file-I/O + # races). 500 ms is the upper bound; typical Popen on Windows completes + # in <50 ms, so most scans still feel instant. On timeout, return + # `started: true` with `startup_pending: true` so existing UIs that + # ignore the new key keep working. + spawn_event = threading.Event() + spawn_result: dict = {} + t = threading.Thread( + target=_scan_worker, + args=(cmd, creationflags, scan_since, scan_roots, spawn_event, spawn_result), + daemon=True, name="scan-worker", + ) with _scan_lock: _scan_thread = t t.start() - return {"ok": True, "scanning": True, "started": True, "scan_roots": scan_roots} + spawn_event.wait(timeout=0.5) + if spawn_result.get("spawn_ok") is True: + return {"ok": True, "scanning": True, "started": True, "scan_roots": scan_roots} + if spawn_result.get("spawn_ok") is False: + return {"ok": False, "scanning": False, "started": False, + "error": spawn_result.get("error", "spawn failed"), + "scan_roots": scan_roots} + # Timeout — Popen hasn't returned either way yet. Defer to scan-progress + # polling for the eventual outcome; flag the response so diagnostics can + # tell this apart from a clean fast start. + return {"ok": True, "scanning": True, "started": True, + "startup_pending": True, "scan_roots": scan_roots} def _deferred_kill(proc: subprocess.Popen, delay_s: float = 5.0) -> None: @@ -1896,6 +2437,51 @@ def handle_scan_cancel(payload: dict) -> dict: return {"ok": False, "error": str(e)} +def _add_scan_estimates(state: dict) -> None: + """Add conservative percent/ETA fields based on currently-known totals.""" + jobs = state.get("remote_jobs") if isinstance(state.get("remote_jobs"), list) else [] + known_total = 0.0 + processed = 0.0 + known_count = 0 + for job in jobs: + if not isinstance(job, dict): + continue + total = job.get("total") + total_num = float(total) if isinstance(total, (int, float)) and total >= 0 else None + files = job.get("files") + skipped = job.get("skipped") + files_num = int(files) if isinstance(files, (int, float)) and files >= 0 else 0 + skipped_num = int(skipped) if isinstance(skipped, (int, float)) and skipped >= 0 else 0 + processed_num = files_num + skipped_num + if total_num is not None: + known_total += total_num + known_count += 1 + processed += min(processed_num, total_num) + else: + processed += processed_num + if known_total <= 0: + return + + percent = min(100.0, max(0.0, (processed / known_total) * 100.0)) + state["scan_files_done"] = int(processed) + state["scan_files_total_known"] = int(known_total) + state["scan_total_known_complete"] = bool(jobs) and known_count == len(jobs) + state["scan_percent"] = round(percent, 1) + + if not state.get("scanning"): + return + try: + started = datetime.fromisoformat(state.get("started_at") or "") + elapsed_s = max(0.0, (datetime.now() - started).total_seconds()) + except (TypeError, ValueError): + elapsed_s = 0.0 + if processed > 0 and elapsed_s > 0: + rate = processed / elapsed_s + remaining = max(0.0, known_total - processed) + state["scan_rate_files_per_s"] = round(rate, 2) + state["scan_eta_s"] = round(remaining / rate, 1) if rate > 0 else None + + def handle_scan_progress(payload: dict) -> dict: """Return current scan progress from SCAN_STATE_FILE.""" with _scan_lock: @@ -1908,8 +2494,14 @@ def handle_scan_progress(payload: dict) -> dict: if not running and state.get("scanning"): state["scanning"] = False state["done"] = True + _add_scan_estimates(state) state.pop("ok", None) return {"ok": True, **state} + except json.JSONDecodeError: + # scan-state.json is a live polling file. If a reader catches it during + # a replace edge or external scanner hiccup, treat it as transient + # state-pending instead of surfacing a host_error alert. + return {"ok": True, "scanning": running, "done": not running, "state_pending": True} except Exception as e: return {"ok": False, "error": str(e)} @@ -2082,6 +2674,7 @@ def handle_list_remotes(payload: dict) -> dict: DISPATCH = { "search": handle_search, "bulk_search": handle_bulk_search, + "check_tabs": handle_check_tabs, "dupe_review": handle_dupe_review, "delete_batch": handle_delete_batch, "library_issues": handle_library_issues, @@ -2104,20 +2697,90 @@ DISPATCH = { "delete_skipped": handle_delete_skipped, "get_keep_ranking": handle_get_keep_ranking, "save_keep_ranking": handle_save_keep_ranking, + "clear_events_log": lambda payload: _handle_clear_events_log(), + "save_alerts_config": handle_save_alerts_config, + "test_alerts_config": handle_test_alerts_config, + "get_alerts_config": handle_get_alerts_config, } +def _handle_clear_events_log() -> dict: + """Truncate the structured events log. Called from the extension's + Diagnostics pane to reset connection lifecycle and per-RPC trace data + without restarting the host process.""" + try: + EVENTS_LOG.write_text("", encoding="utf-8") + except OSError as e: + return {"ok": False, "error": str(e)} + return {"ok": True, "path": str(EVENTS_LOG)} + + def main(): - log(f"--- host start pid={os.getpid()} ---") + pid = os.getpid() + started_at = time.monotonic() + msg_count = 0 + first_action: str | None = None + actions_seen: dict[str, int] = {} + log(f"--- host start pid={pid} ---") + log_event("conn_open", pid=pid) while True: try: msg = read_message() + except StdinClosed as sc: + uptime_s = round(time.monotonic() - started_at, 2) + top = sorted(actions_seen.items(), key=lambda kv: -kv[1])[:5] + top_str = ", ".join(f"{k}={v}" for k, v in top) or "none" + log(f"stdin closed kind={sc.kind} pid={pid} uptime={uptime_s}s msgs={msg_count} first={first_action!r} top={top_str}") + log_event("conn_close", pid=pid, kind=sc.kind, uptime_s=uptime_s, + msg_count=msg_count, first_action=first_action, + top_actions=dict(top), + partial_got=sc.got if sc.kind != "clean_eof" else None, + partial_expected=sc.expected if sc.kind != "clean_eof" else None) + # Alert only on ABNORMAL closes. clean_eof = browser intentionally + # disconnected (SW recycle, extension unloaded) — not interesting. + if sc.kind != "clean_eof": + post_discord_alert( + kind=sc.kind, + alert_source=f"conn_close:{sc.kind}", + summary=f"stdin closed mid-message (got {sc.got}/{sc.expected} bytes)", + detail=( + f"pid={pid}\nuptime={uptime_s}s\nmsgs={msg_count}\n" + f"first_action={first_action}\ntop_actions={top_str}" + ), + fields=[ + {"name": "PID", "value": str(pid), "inline": True}, + {"name": "Uptime", "value": f"{uptime_s}s", "inline": True}, + {"name": "Msgs", "value": str(msg_count), "inline": True}, + ], + ) + break except Exception as e: - log(f"read error: {e}\n{traceback.format_exc()}") + log(f"read error pid={pid} msgs={msg_count}: {e}\n{traceback.format_exc()}") + log_event("conn_close", pid=pid, kind="read_error", error=str(e), + msg_count=msg_count, first_action=first_action) + post_discord_alert( + kind="read_error", + alert_source="read_message_exception", + summary="Host stdin read raised an exception", + detail=f"{type(e).__name__}: {e}\nfirst_action={first_action}\nmsgs={msg_count}", + fields=[{"name": "PID", "value": str(pid), "inline": True}], + ) break if msg is None: + # Belt-and-suspenders — old code path. read_message now raises instead. log("stdin closed, exiting") break + msg_count += 1 + msg_action = msg.get("action", "?") + # Caller-supplied correlation key (independent of native req_id; survives + # process spawn since sendNativeMessage opens a fresh host each call). + client_req_id = msg.get("client_req_id") or None + if first_action is None: + first_action = msg_action + log_event("conn_first_msg", pid=pid, first_action=first_action, + extension_id=msg.get("extension_id"), + client_req_id=client_req_id) + actions_seen[msg_action] = actions_seen.get(msg_action, 0) + 1 req_id = msg.get("req_id") action = msg.get("action", "search") handler = DISPATCH.get(action) @@ -2125,14 +2788,48 @@ def main(): write_message({"req_id": req_id, "ok": False, "error": f"unknown action {action}"}) continue t0 = time.monotonic() + exc_kind: str | None = None try: resp = handler(msg) except Exception as e: - log(f"handler error: {e}\n{traceback.format_exc()}") - resp = {"ok": False, "error": str(e)} + log(f"handler error: action={action} req_id={req_id}: {e}\n{traceback.format_exc()}") + exc_kind = type(e).__name__ + resp = {"ok": False, "error": str(e), "error_kind": "exception", + "exception_type": exc_kind} + post_discord_alert( + kind="exception", + alert_source=f"handler_exception:{action}", + summary=f"Handler raised {exc_kind}", + detail=f"action={action}\nerror={e}\n\n{traceback.format_exc()[-1500:]}", + fields=[ + {"name": "Action", "value": _md_code_inline(action), "inline": True}, + {"name": "Exception", "value": _md_code_inline(exc_kind), "inline": True}, + ], + ) elapsed_ms = round((time.monotonic() - t0) * 1000) + # Stamp req_id + client_req_id BEFORE sizing so resp_bytes reflects the + # actual wire size, including correlation fields. Without this, a + # response just under the 1 MiB cap could pass the size check here but + # exceed it after stamping — and the log would mislead. + resp["req_id"] = req_id + if client_req_id: + resp["client_req_id"] = client_req_id + try: + raw_size = len(json.dumps(resp).encode("utf-8")) + except Exception: + raw_size = -1 + shrunk_resp = _shrink_response(resp) + was_truncated = bool(shrunk_resp.get("truncated") and not resp.get("truncated")) # Structured event log — include search-specific fields for analytics. - event_extra: dict = {"ok": resp.get("ok"), "elapsed_ms": elapsed_ms} + event_extra: dict = {"ok": resp.get("ok"), "elapsed_ms": elapsed_ms, + "resp_bytes": raw_size} + if was_truncated: + event_extra["truncated"] = True + event_extra["truncated_reason"] = shrunk_resp.get("truncated_reason", "") + if exc_kind: + event_extra["exception"] = exc_kind + if client_req_id: + event_extra["client_req_id"] = client_req_id if action == "search": event_extra["id"] = msg.get("id") event_extra["hits"] = resp.get("hits") @@ -2140,14 +2837,46 @@ def main(): elif action in ("delete", "undo_delete", "scan"): if not resp.get("ok"): event_extra["error"] = (resp.get("error") or "")[:200] + elif action == "check_tabs": + event_extra["items"] = len(msg.get("items") or []) + if not resp.get("ok"): + event_extra["error"] = (resp.get("error") or "")[:200] # Don't log every scan_progress poll — it'd flood the events log. if action not in ("scan_progress",): log_event(action, **event_extra) - resp["req_id"] = req_id + # Bracket the write so we can prove whether the response actually left + # the host. write_start = about to flush; write_ok = flush returned. + # If a sample shows write_start but no write_ok → write itself failed. + # If write_ok exists but caller never got it → Chromium NM plumbing lost it. + log_event("write_start", rpc_action=action, req_id=req_id, + client_req_id=client_req_id, size=raw_size) try: - write_message(resp) + write_message(shrunk_resp) + # Wire size = what actually went out (post-shrink, post-stamp). If + # shrink fired, this differs from raw_size; both useful for diagnosing + # cap-related disconnects. + try: + wire_size = len(json.dumps(shrunk_resp).encode("utf-8")) + except Exception: + wire_size = -1 + log_event("write_ok", rpc_action=action, req_id=req_id, + client_req_id=client_req_id, + raw_size=raw_size, wire_size=wire_size, + shrunk=(wire_size != raw_size)) except Exception as e: - log(f"write error: {e}") + log(f"write error: action={action} req_id={req_id} size={raw_size}: {e}\n{traceback.format_exc()}") + log_event("write_error", rpc_action=action, req_id=req_id, + client_req_id=client_req_id, size=raw_size, error=str(e)) + post_discord_alert( + kind="write_error", + alert_source=f"write_message_exception:{action}", + summary="Failed to write response to extension", + detail=f"action={action}\nsize={raw_size}\nerror={e}", + fields=[ + {"name": "Action", "value": _md_code_inline(action), "inline": True}, + {"name": "Bytes", "value": str(raw_size), "inline": True}, + ], + ) break diff --git a/host/register-host.bat b/host/register-host.bat index dbc5f2a..2214fa7 100644 --- a/host/register-host.bat +++ b/host/register-host.bat @@ -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 diff --git a/manifest.json b/manifest.json index 3ddd06a..853f819 100644 --- a/manifest.json +++ b/manifest.json @@ -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": [""], + "host_permissions": [ + "" + ], "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": [""] + } + ], "content_scripts": [ { - "matches": [""], - "js": ["content.js"], + "matches": [ + "" + ], + "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" } diff --git a/bulk-check.css b/src/bulk-check/bulk-check.css similarity index 100% rename from bulk-check.css rename to src/bulk-check/bulk-check.css diff --git a/bulk-check.html b/src/bulk-check/bulk-check.html similarity index 100% rename from bulk-check.html rename to src/bulk-check/bulk-check.html diff --git a/bulk-check.js b/src/bulk-check/bulk-check.js similarity index 100% rename from bulk-check.js rename to src/bulk-check/bulk-check.js diff --git a/options-cache.js b/src/options/options-cache.js similarity index 96% rename from options-cache.js rename to src/options/options-cache.js index fb8cc16..ae0189b 100644 --- a/options-cache.js +++ b/src/options/options-cache.js @@ -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) => `
! ${escapeHtml(m.remote)} · never scanned
` diff --git a/options-diagnostics.js b/src/options/options-diagnostics.js similarity index 63% rename from options-diagnostics.js rename to src/options/options-diagnostics.js index 8d523b5..b4c6c64 100644 --- a/options-diagnostics.js +++ b/src/options/options-diagnostics.js @@ -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 = `error reading log: ${escapeHtml(e.message || String(e))}`; + return; + } + if (errOnly) entries = entries.filter((e) => !e.ok); + if (!entries.length) { + out.innerHTML = `${errOnly ? "no errors recorded" : "no RPC calls recorded yet"}`; + 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 ? ` · TRUNCATED${e.truncated_reason ? " (" + escapeHtml(e.truncated_reason) + ")" : ""}` : ""; + const inflight = e.inflight != null ? ` · ${e.inflight} inflight` : ""; + const head = `
${escapeHtml(_fmtNativeLogTime(e.ts))} ${ok ? "✓" : "✗"} ${escapeHtml(action)} · ${escapeHtml(latency)}${size}${truncated}${inflight}
`; + const tail = !ok + ? `
${escapeHtml(e.error_kind || "error")}: ${escapeHtml(e.error || "")}
` + : ""; + return `
${head}${tail}
`; + }).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 = '
repairing…updating reachable native host manifest and user registration
'; + out.innerHTML = '
repairing…launching install-host.ps1 (UAC prompt will appear)
'; 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 = ` -
Repair applied${escapeHtml(response.message || "native host registration repaired")}
-
iManifest${escapeHtml(response.manifest_path || "")}
-
iUser registry${escapeHtml(`${regs} HKCU registration entr${regs === 1 ? "y" : "ies"} updated`)}
-
!Restart requiredFully close Brave, reopen it, reload the extension, then click Verify Registration. If Brave still blocks the host, run the registration steps shown by Diagnostics.
+
Launcher started${escapeHtml(response.message || "install-host.ps1 launched")}
+
iScript${escapeHtml(response.script_path || "")}
+
iManifest target${escapeHtml(response.manifest_path || "")}
+
!Next steps1) 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.
`; } @@ -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 + ? `` + : ""; out.innerHTML = `
!Setup requiredNative host registration must be fixed before cache, runtime, and host checks can run.
!Likely cause${escapeHtml(cause)}
iHost message${escapeHtml(error)}
Fix on this PC${escapeHtml(fix)}
-
iExtension ID${escapeHtml(extensionId)}
1Run register-host -
${escapeHtml(registerCommand)}
${escapeHtml(`Run ${registerCommand}\nWhen it asks for the extension ID, enter:\n${extensionId}\n\nPowerShell alternative:\n${installCommand}`)}
- +
${escapeHtml(registerCommand)}
${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.`)}
+ ${openFolderBtn}
2Restart BraveClose every Brave window/process, reopen Brave, then reload the extension.
3Verify
@@ -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); } diff --git a/options-dupe-review.js b/src/options/options-dupe-review.js similarity index 96% rename from options-dupe-review.js rename to src/options/options-dupe-review.js index 8e5849e..da42ef5 100644 --- a/options-dupe-review.js +++ b/src/options/options-dupe-review.js @@ -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(); } } diff --git a/options-library-issues.js b/src/options/options-library-issues.js similarity index 62% rename from options-library-issues.js rename to src/options/options-library-issues.js index 0d901e2..3d1b6b5 100644 --- a/options-library-issues.js +++ b/src/options/options-library-issues.js @@ -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 = `
Error: ${escapeHtml(r?.error || "no response")}
`; 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(`
✓ No library issues found. All filenames are canonical.
`); } else { - parts.push(`
${total} file${total !== 1 ? "s" : ""} with non-canonical names — ${brackets.length} bracket-wrapped, ${nohyphens.length} no-hyphen
`); + const typeButtons = [ + ["all", "All", total], + ["noncanonical", "Noncanonical", noncanonicalRes.length], + ["missing", "Missing res", missingRes.length], + ].map(([type, label, count]) => ( + `` + )).join(""); + parts.push(`
+ ${total} cache issue${total !== 1 ? "s" : ""} — ${brackets.length} bracket-wrapped, ${nohyphens.length} no-hyphen, ${missingRes.length} missing resolution tag, ${noncanonicalRes.length} noncanonical resolution + ${typeButtons} +
`); 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 `
+ return `
${tagLabel}
${escapeHtml(fname)} @@ -44,15 +130,50 @@ function renderLibraryIssues(r) {
`; }; + const makeReportRow = (entry, tagLabel = "no res", tagClass = "missingres") => { + const fname = entry.filename || entry.path.split("/").pop(); + return `
+ ${escapeHtml(tagLabel)} +
+ ${escapeHtml(fname)} + ${escapeHtml(entry.path)} +
+ ${escapeHtml(entry.size_human || "")} + ${escapeHtml(_libraryIssueKindLabel(entry))} +
`; + }; - if (brackets.length) { + if (showRenameable && brackets.length) { parts.push(`
Bracket-wrapped IDs (${brackets.length})
`); parts.push(brackets.map((e) => makeRow(e, "bracket", "[ ]")).join("")); } - if (nohyphens.length) { + if (showRenameable && nohyphens.length) { parts.push(`
No-hyphen IDs (${nohyphens.length})
`); parts.push(nohyphens.map((e) => makeRow(e, "nohyphen", "no hyphen")).join("")); } + if (showNoncanonical && noncanonicalRes.length) { + parts.push(`
Resolution present, noncanonical (${noncanonicalRes.length})
`); + 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]) => ( + `` + )).join(""); + const visibleMissingRes = _libraryIssueExportItems(r).missingResolution; + parts.push(`
+ Missing resolution tag (${missingRes.length}) + ${extButtons} +
`); + 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 = `no scan job recorded yet`; @@ -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 `
-
${escapeHtml(j.remote || "?")} · ${escapeHtml(j.status || "queued")}
-
${escapeHtml(detail)}
+
+ ${escapeHtml(j.remote || "?")} + ${escapeHtml(j.status || "queued")} + ${pct != null ? `${pct}%` : ""} +
+
${escapeHtml(detail)}
${pct != null ? `
` : ""}
`; }).join(""); scanJobOut.innerHTML = ` -
${escapeHtml(jobLabel)}${when ? ` · ${escapeHtml(when)}` : ""}
-
${escapeHtml(status)}${escapeHtml(summary || "scan job")}
+
+ ${escapeHtml(jobLabel)} + ${when ? `${escapeHtml(when)}` : ""} +
+
+ ${escapeHtml(status)} + ${escapeHtml(meta || "scan job")} +
+ ${metrics.length ? `
${metrics.map(([label, value]) => ` + ${escapeHtml(label)}${escapeHtml(value)} + `).join("")}
` : ""} ${retiredRoots.length ? `
Historical scan roots not in current config: ${escapeHtml(retiredRoots.join(", "))}. They are shown because this job was recorded before the scan roots changed.
` : ""} ${r.error ? `
${escapeHtml(r.error)}
` : ""} ${jobRows || `
waiting for remote progress...
`} @@ -482,4 +711,3 @@ document.getElementById("library-issues-modal").addEventListener("click", (e) => _optScanTimer = setInterval(_pollOptProgress, 1500); }); })(); - diff --git a/options-profiles.js b/src/options/options-profiles.js similarity index 100% rename from options-profiles.js rename to src/options/options-profiles.js diff --git a/options-rules-editors.js b/src/options/options-rules-editors.js similarity index 100% rename from options-rules-editors.js rename to src/options/options-rules-editors.js diff --git a/src/options/options-shared.js b/src/options/options-shared.js new file mode 100644 index 0000000..b0c25a8 --- /dev/null +++ b/src/options/options-shared.js @@ -0,0 +1,9 @@ +function escapeHtml(s) { + return String(s ?? "").replace(/[&<>"']/g, (c) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }[c])); +} diff --git a/options.css b/src/options/options.css similarity index 84% rename from options.css rename to src/options/options.css index 63cacb6..ccea2f5 100644 --- a/options.css +++ b/src/options/options.css @@ -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 */ diff --git a/options.html b/src/options/options.html similarity index 87% rename from options.html rename to src/options/options.html index 482ed48..17cde55 100644 --- a/options.html +++ b/src/options/options.html @@ -16,30 +16,26 @@ Extension settings
-
Scanning
-
Scan Behavior
-
Overlays
+
Console
+
Duplicate Review
+
Cache & Scans
+
Library Issues
+
Bulk Check lives in its own window — popup launcher, not sidebar.
-
Library
-
Profiles
-
Cache & Scans
-
Library Review
+
Settings
+
Profiles
+
Scan Behavior
+
Matching Rules
+
Site Extraction
+
Overlays
+
Deletion
-
Matching
-
Site Extraction
-
ID Rules
-
-
-
System
-
Setup
-
Diagnostics
-
Debug Tools
-
-
-
Danger
-
×Deletion
+
Support
+
Setup
+
Diagnostics
+
Debug Tools
@@ -47,7 +43,7 @@
-
+

Scan Behavior

Choose when rclone-jav checks the current page.
@@ -286,10 +282,10 @@
- +
-

ID Rules

+

Matching Rules

Normalize odd IDs and teach rc-jav how multipart filename suffixes should stay distinct.
@@ -406,11 +402,11 @@
- -
+ +
-

Library Review

-
Review duplicate groups and fix non-canonical filenames in your library. (Bulk ID Check now opens in its own window from the popup.)
+

Duplicate Review

+
Review cached duplicate groups and tune the KEEP ranking that picks the surviving file.
@@ -469,18 +465,26 @@
+
+ + +
+
+

Library Issues

+
Find cache-only filename hygiene issues. Rename canonical-name fixes now; review missing resolution tags for later processing.
+
+
Library issues
-
Find files with non-canonical names: bracket-wrapped IDs like [REAL-779].mp4 and no-hyphen IDs like MVSD312.avi. Rename suggestions are computed from cache — no network required.
+
Find files with non-canonical names and missing final resolution tags like BLK-474.mp4. Rename suggestions are computed from cache — no network required.
-
- +

Setup

@@ -501,6 +505,24 @@
+
+
Alerts
+
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.
+
+ + +
+
+ + Both paths fire on real errors — test each to confirm Discord receives them. +
+
PC label (optional, embedded in alerts so you can tell which PC fired)
+ +
+ +
+
+
@@ -573,6 +595,18 @@
+
+
Native messaging log
+
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.
+
+ + + + +
+
+
+
Native host registration
Checks the manifest, extension ID permission, and Windows registry entries used by Brave/Chrome native messaging.
@@ -618,11 +652,10 @@
- - - - - + + + +
@@ -742,14 +775,15 @@ @@ -791,6 +825,7 @@ + diff --git a/options.js b/src/options/options.js similarity index 78% rename from options.js rename to src/options/options.js index a9c5f49..d6dfb6d 100644 --- a/options.js +++ b/src/options/options.js @@ -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 + ? `
iKeep Ranking${escapeHtml(`${(keepRanking.priority_folders || []).length} VIP folder(s), ${(keepRanking.format_preference || []).length} format(s) — will overwrite Python config.json`)}
` + : `
iKeep RankingNot included in this export (file may be from older version)
`; document.getElementById("import-modal-body").innerHTML = `
!OverwriteCurrent settings will be replaced by ${escapeHtml(Object.keys(sanitized).length)} imported value(s).
iProfiles${escapeHtml(Array.isArray(sanitized.profiles) ? `${sanitized.profiles.length} profile(s) in this import` : "No profile list in this import")}
+ ${krRow}
${sanitized.enableDelete ? "!" : "i"}Deletion${escapeHtml(sanitized.enableDelete ? `Enabled in imported settings (${sanitized.deleteMode || "trash"} mode)` : "Not enabled by this import")}
${dropped.length ? `
!Ignored keys${escapeHtml(`${dropped.length}: ${dropped.slice(0, 8).join(", ")}${dropped.length > 8 ? "..." : ""}`)}
` : `
SchemaAll imported keys are recognized.
`} `; 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 = `no recent activity yet`; @@ -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"); diff --git a/popup.css b/src/popup/popup.css similarity index 100% rename from popup.css rename to src/popup/popup.css diff --git a/popup.html b/src/popup/popup.html similarity index 100% rename from popup.html rename to src/popup/popup.html diff --git a/popup.js b/src/popup/popup.js similarity index 95% rename from popup.js rename to src/popup/popup.js index 0e6c713..deb3ecc 100644 --- a/popup.js +++ b/src/popup/popup.js @@ -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 = ""; diff --git a/src/shared/id-extract.js b/src/shared/id-extract.js new file mode 100644 index 0000000..c63f25f --- /dev/null +++ b/src/shared/id-extract.js @@ -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);