Compare commits

..

11 Commits

Author SHA1 Message Date
admin 87d3f6d12c Global hash strip: suppress re-scan on anchor-only URL changes 2026-05-27 00:50:15 +02:00
admin 2d6a95682f Sync working tree before initial Gitea push
- File reorg: popup/options/bulk-check moved to src/ subdirs
- Shared modules: src/shared/id-extract.js, src/options/options-shared.js
- Host updates: rcjav-host.py + register/install scripts
- .gitignore expanded
2026-05-26 22:42:15 +02:00
admin 0e230320a9 Close out step 6c + record final roadmap state 2026-05-23 11:19:24 +02:00
admin d0a2def788 Step 6c: extract Diagnostics + Profiles + Rules Editors from options.js
Final options.js split. Three new files:

  options-diagnostics.js     245 lines
  options-profiles.js        265 lines
  options-rules-editors.js   328 lines  (adapters + ID normalizers
                                          + custom part detectors)

options.js: 1852 → 1014 lines (838 extracted, ~45% reduction).

Script-tag order in options.html now (load order matters for
top-level let bindings shared across files, e.g. _configuredScanRoots):

  options-cache.js
  options-dupe-review.js
  options-library-issues.js
  options-diagnostics.js
  options-profiles.js
  options-rules-editors.js
  options.js  (entry: IIFE bottom, escapeHtml, overlay previews,
               element picker, paths)

The picker, overlay-preview, and no-match overlay code stays in
options.js — those are tightly intertwined with multiple settings
panes and not worth further splitting today.

node --check passes on each file individually and on the concatenated
load-order stream. Line count of concat (3144) matches the pre-split
sum exactly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 11:17:55 +02:00
admin 5e79c69d0c Record step 11 fast-path benchmark verdict (keep) 2026-05-23 11:12:47 +02:00
admin c17ac9e1e7 Step 10j (host + extension): cache contract three-state UX
Completes the two-tier cache contract from step 9 / docs/CACHE_CONTRACT.md
on the extension side. The Python side shipped in the Python repo at
33c495a.

Host (rcjav-host.py):
  - fetch_rules_info() memoizes per-script-path calls to
    `rc-jav.py --print-rules-info` so handle_cache_status doesn't pay
    the Python startup cost on every poll.
  - _cache_freshness_fields(data, rules_info) computes the new
    cache_schema / id_rules / id_rules_signature trio + their three
    *_match booleans + cache_state ('fresh' / 'stale_by_rules' /
    'schema_mismatch' / 'missing'). Legacy version:3 caches still on
    disk report as stale_by_rules with cache_schema_match=True (we'll
    migrate them at next load_cache).
  - New handle_reextract_ids() action forwards to
    `rc-jav.py --reextract --format json` with a 5-minute timeout.

background.js:
  - New `reextract-ids` message forwards to host with a 300s timeout.

options-cache.js + options-library-issues.js:
  - renderCacheContractBanner() paints a three-state banner above the
    per-remote list: green ✓ fresh / amber ! stale-by-rules (with
    "Re-extract IDs (fast, no rescan)" chip button) / red ✗ schema
    mismatch. Includes a snippet of the cache signature for diagnostics.
  - Delegated click handler in options-library-issues.js catches
    .cache-reextract, sends the message, shows transient
    "Re-extracting…" state, and replaces the button with a per-summary
    line ("Re-extracted N IDs · X changed · Y unchanged · Z dropped").
  - rules_info_error from the host surfaces as its own amber line above
    the banner.

node --check passes on background.js, options-cache.js,
options-library-issues.js individually and on the concatenation of all
four script files. python -m py_compile passes on rcjav-host.py.
Behavioral verification requires reloading the unpacked extension and
running through:
  - Check Cache → banner shows "stale by rules" amber (legacy v3 cache)
  - Click "Re-extract IDs" → fast path runs, summary appears
  - Check Cache again → banner now shows "Cache up to date" green

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 11:07:26 +02:00
admin 1a57b6a4f9 Record step 10 package split completion (10a-10i) 2026-05-22 22:02:27 +02:00
admin 41e9a500d0 Step 9: cache contract design doc
Adds docs/CACHE_CONTRACT.md defining the two-tier replacement for
today's single CACHE_VERSION=3 constant:

  cache_schema       force rebuild on mismatch (today's semantics)
  id_rules           mark stale, allow lazy re-extract w/o rescan
  id_rules_signature sha256 over canonical text of all extraction
                     rule sources (regexes, normalizers, part
                     detectors, FC2 handling, user-config rules)
                     as a belt-and-braces drift check

Documents:

  - new cache.json header shape
  - one-shot in-place migration for legacy `version: 3` users (no
    forced rescan)
  - behavior matrix for the three resulting states
  - extension UX: fresh / stale-by-rules amber / schema-mismatch red
  - new "Re-extract IDs" action that walks files[] in place and
    never touches rclone
  - what counts as a rules change vs. unrelated code change
  - open questions deferred to step 10 (per-remote tracking,
    custom-rules signature handling, host wiring)

No code changes — step 10 implements. This commit only locks the
contract so step 10 has a single source of truth for both the
Python and extension sides.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 21:37:01 +02:00
admin 64ab8e4cfb Step 8: shared fixture corpus seeded at D:\DEV\Project\rclone-jav\fixtures\
Adds a shared JAV-ID fixture corpus at the Python repo root (conceptually
joint with this extension). Three JSON files:

  filename-extraction.json   12 cases — Python extract_id contract
  query-extraction.json      10 cases — extension content.js normalizeId
  shared-normalization.json   5 cases — both sides must agree

Plus a README.md documenting format, ownership, and one intentional
cross-side divergence (the compact `FC2PPV1841460` form: extension
normalizes it from page titles, Python `extract_id` does not — it
doesn't realistically appear in filenames on disk).

A self-contained Python runner at fixtures/run.py exercises extract_id
and normalize_id against the relevant corpora. No third-party deps,
loads rc-jav.py in place. All 17 Python-side cases pass against the
current rc-jav.py. Output uses ASCII separators (`->`, `|`) instead of
unicode arrows/middots so the runner works on Windows cp1252 consoles.

No Node-side runner today: content.js lives inside an injected IIFE
and a Node runner would have to duplicate the regexes. The JSON corpus
is the canonical spec until content.js is refactored to be importable.

This commit only updates AGENTS.md in this repo — the fixture files
themselves live in the Python repo at D:\DEV\Project\rclone-jav\fixtures\
which is not currently git-tracked.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 21:34:14 +02:00
admin ad4df28a66 Step 7a: Bulk ID Check moves to a detached popup window
New files:
  bulk-check.html, bulk-check.js, bulk-check.css

Popup gains a 📋 launcher button next to the ⚙ Options gear. Clicking
it sends `open-bulk-check` to background.js and closes the popup;
background.js owns window lifecycle:

  - chrome.storage.session.bulkCheckWindowId stashes the open window id
  - existing id → chrome.windows.update({ focused, drawAttention })
  - missing or stale id → chrome.windows.create({ type:'popup',
    width:640, height:540 }) and stash the new id
  - chrome.windows.onRemoved clears the stale id on close

Last-paste persisted to chrome.storage.local.bulkCheckLastPaste,
debounced 500ms on input, restored on window open. quickMode is read
from settings at run time, matching previous behavior. Ctrl/Cmd+Enter
inside the textarea triggers the check.

Options page no longer carries the Bulk ID Check fieldset: removed
from options.html (Library Review pdesc updated to note the
relocation) and the matching handlers from options.js
(1903 → 1852 lines). No manifest permission changes — own-page
chrome.windows.create needs no extra permission.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 21:27:12 +02:00
admin e4ee06b19f Step 6b: extract Library Issues from options.js
Continues the options.js split. New file:

  options-library-issues.js  453 lines

After this step:

  options-cache.js            161 lines
  options-dupe-review.js      616 lines
  options-library-issues.js   453 lines
  options.js                 1903 lines  (was 2356 after step 6)

Library Issues block was fully self-contained (lastLibraryIssues,
_libraryIssuesDirty, renderLibraryIssues, _closeLibraryIssues, and the
bottom IIFE wrapping _optScanTimer / _setOptScanningState /
_pollOptProgress for optimization-scan progress polling). No external
callers of its identifiers.

Reads _configuredScanRoots / _cacheSkippedByRemote and calls
rememberConfiguredScanRoots from options-cache.js by bare reference —
same cross-file binding pattern proven in step 6.

node --check passes on each file and on the concatenation of all four
files in load order. Concat = 3133 lines, matching pre-split total.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 21:21:58 +02:00
32 changed files with 5367 additions and 3077 deletions
+4
View File
@@ -1,3 +1,7 @@
host/__pycache__/ host/__pycache__/
host/logs/ host/logs/
host/state/ host/state/
*.bak
rclone-jav-library-issues-*.json
.vscode/
.idea/
+17 -7
View File
@@ -136,17 +136,27 @@ Done in rc-jav catalog loading. Catalog CSV/XML paths are normalized from Window
3. **Transfer Assistant wizard deleted.** "Setup & Transfer" pane renamed to "Setup". Replacement: Extension ID display + Copy button added to Diagnostics → Native host registration fieldset (always visible, not failure-gated). Sidebar entry, fieldset, modal, and ~107 lines of JS removed. 3. **Transfer Assistant wizard deleted.** "Setup & Transfer" pane renamed to "Setup". Replacement: Extension ID display + Copy button added to Diagnostics → Native host registration fieldset (always visible, not failure-gated). Sidebar entry, fieldset, modal, and ~107 lines of JS removed.
5. **Recent Activity + Search Troubleshooting moved to new Debug Tools pane.** Verified Recent Activity is search-trigger-only by reading `background.js``recordActivity()` is NOT called from `delete-file` handler. No audit-value split needed. New sidebar entry "Debug Tools" under System group; new `pane-debug` houses both fieldsets. 5. **Recent Activity + Search Troubleshooting moved to new Debug Tools pane.** Verified Recent Activity is search-trigger-only by reading `background.js``recordActivity()` is NOT called from `delete-file` handler. No audit-value split needed. New sidebar entry "Debug Tools" under System group; new `pane-debug` houses both fieldsets.
6. **options.js split — Cache & Scans + Duplicate Review paired extraction.** `options.js` 3133 → 2356 lines. New files: `options-cache.js` (161 lines, Cache & Scans block), `options-dupe-review.js` (616 lines, Dup Review + Keep Ranking incl. bottom `loadKeepRanking()` call). Script-tag order in `options.html`: cache → dupe-review → options.js (body bottom). Cross-script binding visibility (vanilla classic scripts share global declarative env): Library Issues code still in options.js reads `_configuredScanRoots` / `_cacheSkippedByRemote` / calls `rememberConfiguredScanRoots` from cache file by bare reference. Calls to `escapeHtml` / `openModal` / `closeModal` / `keepActionViewport` / `clearNativeRepairCard` / `renderNativeMessagingFailure` from extracted files all occur inside event handlers (resolved at call time, after options.js parses). Repo `git init`'d before this step; baseline commit `f8e781f` is the rollback point. Verified by `node --check` on each file and on concatenated script. 6. **options.js split — Cache & Scans + Duplicate Review paired extraction.** `options.js` 3133 → 2356 lines. New files: `options-cache.js` (161 lines, Cache & Scans block), `options-dupe-review.js` (616 lines, Dup Review + Keep Ranking incl. bottom `loadKeepRanking()` call). Script-tag order in `options.html`: cache → dupe-review → options.js (body bottom). Cross-script binding visibility (vanilla classic scripts share global declarative env): Library Issues code still in options.js reads `_configuredScanRoots` / `_cacheSkippedByRemote` / calls `rememberConfiguredScanRoots` from cache file by bare reference. Calls to `escapeHtml` / `openModal` / `closeModal` / `keepActionViewport` / `clearNativeRepairCard` / `renderNativeMessagingFailure` from extracted files all occur inside event handlers (resolved at call time, after options.js parses). Repo `git init`'d before this step; baseline commit `f8e781f` is the rollback point. Verified by `node --check` on each file and on concatenated script.
6b. **options.js split — Library Issues extraction.** `options.js` 2356 → 1903 lines. New file: `options-library-issues.js` (453 lines) — covers `lastLibraryIssues`, `_libraryIssuesDirty`, `renderLibraryIssues`, `_closeLibraryIssues`, and the bottom IIFE that wraps `_optScanTimer` / `_setOptScanningState` / `_pollOptProgress` for optimization-scan progress polling. Block was fully self-contained (no external callers of its identifiers). Reads `_configuredScanRoots` / `_cacheSkippedByRemote` / calls `rememberConfiguredScanRoots` from `options-cache.js` — same cross-file binding pattern proven in step 6. Script-tag order in `options.html`: cache → dupe-review → library-issues → options.js. `node --check` passes on each file and on concatenation; line count of concat (3133) matches pre-split total exactly.
7a. **Bulk Check standalone window.** New `bulk-check.{html,js,css}` opened as detached `chrome.windows.create({ type: 'popup', width: 640, height: 540 })`. Launcher = 📋 icon button in popup header next to ⚙ Options; click sends `open-bulk-check` message to background and closes the popup. Background owns the lifecycle: `openBulkCheckWindow()` reads `chrome.storage.session.bulkCheckWindowId`; existing id → `chrome.windows.update({ focused, drawAttention })`; failure or no id → create new window + stash id. `chrome.windows.onRemoved` clears the stale id on close. Last-paste persisted to `chrome.storage.local.bulkCheckLastPaste` (debounced 500ms), restored on window open. `quickMode` read from settings on each run (parity with old options behavior). Removed the Bulk ID Check fieldset from `options.html` (Library Review pane description updated to note the relocation) and its handlers from `options.js` (1903 → 1852 lines). No manifest permission changes needed.
8. **Shared fixture corpus.** Seeded `D:\DEV\Project\rclone-jav\fixtures\` (top-level in the Python repo, conceptually shared with this extension). Files: `filename-extraction.json` (12 cases, Python `extract_id` contract), `query-extraction.json` (10 cases, extension `content.js` `normalizeId` contract), `shared-normalization.json` (5 cases, both sides must agree), `README.md`, and a self-contained Python runner `run.py` (no third-party deps; imports `rc-jav.py` in place). All 17 Python-side cases pass against current `rc-jav.py`. The runner uses `|` and `->` instead of `·` and `→` so it works on Windows cp1252 consoles. Documented one intentional divergence: the extension normalizes the compact `FC2PPV1841460` form (page-title surface) while Python `extract_id` does not (filename surface — compact form doesn't appear on disk). No Node-side runner today — `content.js` lives in an injected IIFE and importing it would require duplicating regexes; the JSON corpus is the canonical spec until that lands.
9. **Cache contract design — shipped as a design doc, not code.** `docs/CACHE_CONTRACT.md` defines a two-tier model that splits today's single `CACHE_VERSION = 3` into `cache_schema` (force rebuild on mismatch) and `id_rules` (mark stale, allow lazy re-extract without re-scanning). Adds `id_rules_signature` (sha256 over canonical text of all extraction-rule sources, including user-added normalizers from config.json) as a belt-and-braces drift check. Specifies the new cache header shape, a one-shot in-place migration for users on legacy `version: 3` (no forced rescan), the behavior matrix for the three resulting states, and the extension's three-state UX (fresh / stale-by-rules amber / schema-mismatch red) with a new "Re-extract IDs" action that walks `files[]` in place and never touches rclone. Step 10 implements; step 9 only locks the contract.
(Step 4 in the plan is a paired-extraction sub-task of step 6; folded into step 6 ship.) (Step 4 in the plan is a paired-extraction sub-task of step 6; folded into step 6 ship.)
**Pending (in execution order):** 6c. **options.js split — Diagnostics + Profiles + Rules Editors extracted.** Final mechanical split. Three new files: `options-diagnostics.js` (245 lines — extension ID display, runDiagnostics, host status, host repair, native messaging failure renderer), `options-profiles.js` (265 lines — `_knownRemotes`, `_cfgDefaults`, fetchRemotes, buildRemotePicker, profile modal), `options-rules-editors.js` (328 lines — adapters + ID normalizers + custom part detectors with their feedback UI). `options.js` is now **1014 lines** — entry IIFE, settings load/save, backup/restore, recent activity, search test bench, element picker, overlay previews, no-match overlay, `escapeHtml`, and paths. The picker + overlay-preview code stays because it's tightly coupled across multiple settings panes and the JS-DOM call graph would have to be untangled to extract cleanly. Script-tag order in `options.html` now: cache → dupe-review → library-issues → diagnostics → profiles → rules-editors → options.js (entry). `node --check` clean on each file individually and on the concatenated load-order stream. Concat = 3144 lines, matching the pre-6c sum exactly.
- **Step 6b — continue options.js split with Library Issues, Debug Tools handlers, Settings sub-tabs.** Library Issues is the next obvious ~450-line block (lines 15051957 in pre-split numbering, now in options.js mid-section). Reads `_configuredScanRoots` and `_cacheSkippedByRemote` from `options-cache.js` — cross-file binding already exercised, so the extraction is lower risk than the first pair. **Pending:** none. Original roadmap closed. Follow-ups recorded inline above (e.g. step 11's `_load_host_cache` memoization is already shipped via the `_cache_mem` stamp dict). Node-side fixture runner (`fixtures/run-node.mjs`) added so `shared-normalization.json` now genuinely guards cross-side drift — the original step 8 ship noted the gap; it's closed.
- **Step 7a — Bulk Check standalone window.** New `bulk-check.html` opened as detached `chrome.windows.create({ type: 'popup', width: 640, height: 540 })` from a "Bulk Check" launcher button in the popup. Single canonical entry path — NOT a Console sidebar tab. Window dedup via `chrome.storage.session`, last-paste persisted via `chrome.storage.local`. 10. **`rc-jav.py` package split — done (sub-steps 10a10i, shipped across two sessions).** Python repo at `D:\DEV\Project\rclone-jav\` is now git-tracked (baseline `e029e89`); `rc-jav.py` went from 2230 lines to a 25-line shim. New `rcjav/` package contains: `model.py` (24, FileEntry), `ids.py` (243, ID extraction + part detection + normalization + describe_id_match + expand_range), `cache.py` (76, cache.json I/O), `catalog.py` (178, WinCatalog CSV/XML), `dupes.py` (264, keep-ranking + find_dupes + variant alerts), `rclone_io.py` (298, subprocess wrappers + walk_remote + glob escaping), `library.py` (176, library-issues + safe rename), `output.py` (495, rich console + renderers + plain/CSV/JSON outputs), `cli.py` (845, main() + collectors + arg parsing). Pattern across all sub-steps: top-level mutable globals (`PART_RES`, `_KEEP_RANKING`, `BASIC`, `RCLONE_BIN`, `console`, `USE_ANSI`) are read/written only inside their owning module — callers go through setters (`configure_part_patterns`, `set_keep_ranking`, `set_basic`, `set_rclone_bin`, `set_console_no_color`, `set_use_ansi`) so no in-tree code ever sees a stale captured binding. `rc-jav.py` shim does `from rcjav import *` + `from rcjav.cli import main`, so `importlib.spec_from_file_location("rcjav_script", "rc-jav.py")` (used by tests/fixtures/native host) still finds every previously-top-level name. Each sub-step verified at commit time via `python rc-jav.py --help`, `python -m rcjav.cli --help`, `python fixtures/run.py` (17/17 cases), and `python -m unittest tests.test_rules` (5/5).
- **Step 8 — Shared fixture corpus.** Top-level `D:\DEV\Project\rclone-jav\fixtures\` (neutral location, NOT inside Python or extension repo). JSON cases for query-ID extraction (extension), filename ID extraction (Python), shared normalization.
- **Step 9 — Cache contract design.** CACHE_VERSION already exists (currently 3). Add ID_RULES_VERSION concept: schema bump = force rebuild, rules bump = warn-and-mark-stale. (Step 10's cache-contract implementation is split off as step 10j below — design from step 9 is locked, implementation hasn't shipped.)
- **Step 10 — `rc-jav.py` module split** into `rcjav/` package (ids, cache, dupes, catalog, rclone_io, output, cli). Keep `rc-jav.py` as thin entrypoint that imports from `rcjav.cli.main`. 10j. **Cache contract implementation — done.** Two-tier contract from `docs/CACHE_CONTRACT.md` now live end-to-end across Python + host + extension.
- **Step 11 — Host fast-path benchmark and decide.** Measure popup search latency under (a) idle Python and (b) Python actively scanning. If host fast path is the only thing keeping popup responsive under scan = narrow to dict lookup only and document. If not needed = delete entirely.
Python (`rcjav/cache.py` + `rcjav/ids.py` + `rcjav/cli.py`): new constants `CACHE_SCHEMA_VERSION = 1` and `ID_RULES_VERSION = 1`. New `current_rules_signature()` in `rcjav.ids` produces a stable sha256 over the canonical text of every rule that influences a `jav_id` (PRIMARY_ID_RE, COMPOUND_ID_RE, FALLBACK_ID_RE, _NOHYPHEN_ID_RE, _BRACKET_ID_RE, _VARIANT_SUFFIX_RE, _XOFY_PRIORITY_RE, _RESOLUTION_TAG_RE, BUILTIN_PART_RES, PART_RES, FC2 handling toggle). `load_cache(signature)` translates legacy `version: 3` headers in place — no forced rescan; the cache is stamped `id_rules: 0` + signature `"legacy"` so it reads as "stale by rules". `cache_state(cache, sig)` classifies as `fresh` / `stale_by_rules` / `schema_mismatch`. `stamp_current_rules(cache, sig)` updates the header after a full scan or `--reextract`. New `rc-jav.py --reextract` walks `cache["remotes"][r]["files"]` against the live rule set and updates `jav_id` in place (no rclone). Full `--scan` (without `--scan-since`) stamps current rules; incremental `--scan --scan-since` deliberately does not. Verified on the live 7124-file cache.
Host (`rcjav-host.py`): new `--print-rules-info` flag on the Python side returns `{cache_schema, id_rules, id_rules_signature}` cheaply. Host memoizes the result per script path in `_RULES_INFO_CACHE` and augments `cache_status` responses with `cache_schema`, `id_rules`, `id_rules_signature`, the corresponding `expected_*` constants, three `*_match` booleans, and `cache_state` (`fresh` / `stale_by_rules` / `schema_mismatch` / `missing`). Legacy `version: 3` caches still on disk are reported as `stale_by_rules` with `cache_schema_match: true` (we'll migrate them at next `load_cache`). New `reextract_ids` action forwards to `rc-jav.py --reextract --format json` with a 5-minute timeout.
Extension (`background.js` + `options-cache.js` + `options-library-issues.js`): new `reextract-ids` message in `background.js` calls the host with a 300s timeout. `renderCacheContractBanner(r)` in `options-cache.js` paints the three-state inline banner above the per-remote list — green ✓ for fresh, amber ! for stale-by-rules (with a "Re-extract IDs (fast, no rescan)" chip button), red ✗ for schema mismatch. The delegated click handler in `options-library-issues.js` (which already owns the cache-status-results container) catches `.cache-reextract`, sends the message, shows a transient "Re-extracting…" state, and replaces the button with a per-remote summary line ("Re-extracted N IDs · X changed · Y unchanged · Z dropped"). `rules_info_error` from the host surfaces as a separate amber line above the banner.
11. **Host fast-path benchmark — done, decision = keep.** `benchmarks/host-fast-path.py` (Python repo) compares `handle_cached_search_fast` against `rc-jav.py --search ID --cache --format json` on the live 7124-file cache. Idle baseline (5 queries × 5 iterations): fast-path median 0.46ms / p95 0.61ms / max 0.72ms; subprocess median 919ms / p95 1233ms / max 1385ms; **2000× median speedup**. The ~920ms subprocess cost is structural — Python interpreter startup + 1.3 MB cache.json parse — so it applies under idle Python too, not just when a scan is running. The "Python actively scanning" condition from the original framing doesn't change the verdict; it would only make the subprocess path slower while leaving the in-process fast path unaffected. Fast path is already correctly scoped (bails for wildcards, ranges, name searches, `--quick`). Possible follow-up (not in scope): memoize `_load_host_cache` with mtime-based invalidation so the fast path doesn't reparse cache.json on every call — current per-call median is already fast enough that this is optional. See `benchmarks/README.md` for the full write-up.
**Architecture (locked — do not relitigate):** **Architecture (locked — do not relitigate):**
-122
View File
@@ -1,122 +0,0 @@
# rclone-jav (Brave extension + native messaging host)
Session memory for Claude. Read before making changes here.
## Architecture
```
Brave tab title -> content script extracts JAV ID
-> background.js connectNative("com.rcjav.host")
-> host/rcjav-host.bat (portable: py launcher or python on PATH)
-> host/rcjav-host.py
-> subprocess python rc-jav.py --search ID --basic --no-color --format json
-> structured hits back through native port
-> popup or in-page overlay
```
Two separate codebases:
- This repo: Brave extension + native messaging host.
- `D:\DEV\Project\rclone-jav\` — Python rc-jav CLI. The host shells out to `rc-jav.py` here.
## Folder layout (post-rename)
```
D:\DEV\Extensions\Production\rclone-jav\ (PC 1)
D:\DEV\Extensions\Staging\rclone-jav\ (PC 2)
├── manifest.json
├── background.js
├── content.js
├── popup.{html,js,css}
├── options.{html,js}
├── host\
│ ├── rcjav-host.py
│ ├── rcjav-host.bat (portable: py launcher fallback)
│ ├── install-host.ps1 (self-elevates to HKLM)
│ ├── register-host.bat (prompts for ID, calls install-host.ps1)
│ ├── com.rcjav.host.json (generated; UTF-8 NO BOM)
│ └── (logs)
└── docs\
├── INSTALL.md (gotcha table at the bottom)
└── README.md
```
## Critical gotchas (learned the hard way)
| Symptom | Cause | Fix |
|---|---|---|
| "Specified native messaging host not found" | UTF-8 BOM in com.rcjav.host.json | `WriteAllText` with `UTF8Encoding($false)` |
| Same error after registering HKCU | Brave on Windows ignores HKCU on some installs | Register HKLM too. `install-host.ps1` does both. |
| Host launches then disconnects | Python text-mode stdio mangles 4-byte length prefix | `msvcrt.setmode(stdin/stdout, O_BINARY)` at host startup |
| Host log says "stdin closed, exiting" immediately | bat-side stderr leak corrupts protocol | `python -u` + redirect stderr to log file |
| `Missing closing '}'` in install-host.ps1 | Em-dashes in comments + LF endings + Windows PS 5.1 (cp1252 fallback) | Strip em-dashes from .ps1 files, or save with BOM, or use pwsh |
| Brave reload != Brave restart | NM cache survives extension reload | Kill all brave.exe processes then reopen |
| `IBW-902z` page title fails to parse | `\b` after `\d` blocked by following word char | Extension regex uses `[a-zA-Z]?\b` trailing — captured but discarded |
| Delete safety too broad | Allowlist reduced `cq:JAV` to `cq:` | Match full configured prefixes, not remote roots |
| Overlay feels ~1.5s late on SPA pages | `SPA_SETTLE_MS` waits before auto-check | Current value is 800ms; tune carefully if detection gets flaky |
## Internal names — keep as-is
- Native messaging host: `com.rcjav.host` (NOT renamed despite extension rename)
- Window flag in content.js: `__rclonex_loaded__` (idempotency guard for content script re-injection)
- CSS IDs starting with `rclonex-` (overlay)
- Host logs: `host/logs/rcjav-host.log`, `host/logs/rcjav-host-events.log`, `host/logs/rcjav-host-stderr.log`, `host/logs/deletes.log`
- Host scan progress state: `host/state/scan-state.json`
Don't rename these unless there's a real reason. They're orthogonal to the user-facing extension name.
## Settings
Stored in `chrome.storage.sync` under key `settings`. Per-extension-ID namespacing → if extension is reloaded under a different path, settings are wiped.
**Backup/restore lives in Options → Paths → "Backup & restore"** — JSON export/import to survive reloads or PC migrations. Use it before renaming or relocating the extension.
DEFAULT_SETTINGS lives in background.js. Keep in sync with options.html defaults.
## Decision log
### Deletion allowlist uses full prefixes (2026-05-20)
**Decision:** host delete allowlist must use full configured path prefixes (`cq:JAV`, trash dir, etc.), not only remote roots like `cq:`.
**Reasoning:** Reducing `cq:JAV` to `cq:` lets any path on the same rclone remote pass the safety check. Deletion is opt-in but must be tightly scoped.
**Important:** extension delete calls must forward `rcjav_path`, or the host may read the wrong `config.json` and derive the wrong allowlist.
### Toolbar popup setting gates auto-check (2026-05-20)
**Decision:** `triggers.toolbarClick` does not remove the MV3 popup, but it does gate whether the popup auto-runs `checkTab` on open. If disabled, popup stays idle until user clicks Re-Scan.
### Quick search and ID padding (2026-05-20)
**Decision:** rc-jav canonical JAV IDs use at least 3 digits (`ABC-027`) and preserve 4+ digit IDs (`ABCD-1294`). Quick search emits canonical uppercase globs only.
**Reasoning:** user clarified real JAV filenames are never `ABC-27` or `ABC-0027`; they are `ABC-027`. User also never uses lowercase filenames, so quick search should not use rclone `--ignore-case` because it added noticeable delay.
**Operational note:** this changes cache keys. Run `python rc-jav.py --scan` in `D:\DEV\Project\rclone-jav` after this change.
### No-match overlay metadata (2026-05-20)
**Decision:** host search response includes `cache_meta` and `scanned_remotes` from rc-jav JSON so no-match overlays can show what was scanned instead of falling back to "library".
### IBW-902z trailing letter (2026-05-20)
**Decision:** minimal regex fix in extension only. NOT a full variant-suffix rewrite of the index.
**Reasoning:** User's library uses one ID per number (either `IBW-902` OR `IBW-902z`, not both). Page titles failing on `IBW-902z` is the real bug. Extension regex now matches optional trailing letter and discards it. rc-jav's index continues to strip trailing letters at extract_id time. Effective: extension queries `IBW-902` for any title `IBW-902` or `IBW-902z`, finds the file regardless of how it's named on rclone.
**Revisit if:** both `IBW-902.mp4` and `IBW-902z.mp4` ever coexist in library — they'd collide on the same ID. Then implement variant suffix (#var_Z) end-to-end.
### Native messaging host name stayed `com.rcjav.host`
When extension was renamed `rclonex` → `rclone-jav`, the NM host name was NOT renamed. Reason: zero user impact (it's an internal identifier in registry/manifest), but every rename costs registry rewrites + script churn. Not worth it.
### WinCatalog backslash normalization
Deferred. See `D:\DEV\Project\rclone-jav\TODO.md`. Catalog CSV paths use Windows `\` which leaks through to popup display, breaking filename split. Fix is 2 lines in rc-jav.py `load_catalog_csv` / `load_catalog_xml` — apply when catalog feature is used heavily.
## When making changes
- Extension settings schema change → update `DEFAULT_SETTINGS` in background.js AND defaults in options.html + options.js load()
- New native messaging action → handler in rcjav-host.py + DISPATCH map + extension code that sends it
- New options pane → sidebar item in options.html + new `.pane` div + load/save bindings in options.js
- Any rc-jav.py CLI change → host invocation in rcjav-host.py handle_search must keep pace
+610 -379
View File
File diff suppressed because it is too large Load Diff
+8 -41
View File
@@ -11,40 +11,20 @@ if (!window.__rclonex_loaded__) {
window.__rclonex_loaded__ = true; window.__rclonex_loaded__ = true;
(() => { (() => {
// Optional single trailing letter (e.g. IBW-902z) is matched but discarded — // ID-extraction primitives live in src/shared/id-extract.js (loaded by the
// rc-jav's index already drops trailing letters too, so query "IBW-902" finds the file. // manifest content_scripts[] entry before this file).
const ID_RE_DASHED = /\b([A-Za-z][A-Za-z0-9]{1,})-(\d{2,7})[a-zA-Z]?\b/; const { normalizeId: _normalizeId } = self.RCJAV_IDS;
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" },
];
const BUILTIN_SITE_ADAPTERS = [ const BUILTIN_SITE_ADAPTERS = [
{ host: "clearjav.com", selector: "div.meta-chip > h3.meta-chip__value" }, { 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 = []; 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() { async function loadUserNormalizers() {
try { try {
const { settings = {} } = await chrome.storage.sync.get("settings"); const { settings = {} } = await chrome.storage.sync.get("settings");
@@ -59,19 +39,6 @@ chrome.storage.onChanged?.addListener?.((changes, area) => {
}); });
loadUserNormalizers(); loadUserNormalizers();
function normalizeId(text, userNormalizers = _userNormalizers) {
if (!text) return null;
// Try user-defined + built-in normalizers first (FC2-PPV-style oddballs).
const fromNormalizer = applyNormalizers(text, userNormalizers);
if (fromNormalizer) return fromNormalizer.toUpperCase();
let m = text.match(ID_RE_DASHED);
if (!m) m = text.match(ID_RE_UNDASHED);
if (!m) return null;
// Preserve the digits exactly as they appear (incl. leading zeros) — rc-jav --quick
// hands the glob "<ID>*" to rclone --include, which is literal, not numeric.
return `${m[1].toUpperCase()}-${m[2]}`;
}
function hostMatches(pattern, host) { function hostMatches(pattern, host) {
// Glob: '*' = any chars. Case-insensitive. // Glob: '*' = any chars. Case-insensitive.
// Convenience: a bare domain (no '*.') ALSO matches any subdomain — and vice versa. // Convenience: a bare domain (no '*.') ALSO matches any subdomain — and vice versa.
+211
View File
@@ -0,0 +1,211 @@
# Cache contract — design (Step 9)
Status: **design only**. No code changes yet. Step 10 implements.
This document is the source of truth for `cache.json` versioning and
the rebuild policy that both the Python `rc-jav.py` CLI and the
browser extension follow. It supersedes the single `CACHE_VERSION`
constant currently in `rc-jav.py`.
## Why split CACHE_VERSION
`CACHE_VERSION = 3` in `rc-jav.py` today is a single integer that
covers two unrelated things:
1. **Schema** — the shape of `cache.json` itself (top-level keys,
nested object shape, what fields a file entry carries).
2. **Rules** — the ID extraction logic (`extract_id`, normalizers,
part detectors, FC2-PPV handling). These influence the `jav_id`
field stored inside file entries.
Conflating them has a real cost:
- The last `CACHE_VERSION` bump (`3`, comment "extract_id handles
bracket-wrapped IDs + no-hyphen fallback") was a **rules** change.
It forced every user to do a full library rescan, which on large
remotes can take 30+ minutes per remote, even though the file
entries' shape was unchanged.
- A user who hasn't pulled the new rules can't tell from cache.json
whether their existing cache is "wrong shape" (unusable) or "stale
IDs" (usable but missing some matches).
- The extension can't surface the distinction in its UI either, so
the Cache & Scans pane shows a single "stale" state regardless.
## Two-tier model
| Tier | Bumps when | Effect |
|-------------------|--------------------------------------------------------------------|-------------------------------------------------------|
| `cache_schema` | The cache.json structure changes (new field, removed key, etc.) | **Force rebuild.** Cache is unusable. |
| `id_rules` | Any extraction rule changes — regex, normalizer, part detector | **Mark stale.** Cache stays usable; offer re-extract. |
`cache_schema` corresponds to the current `CACHE_VERSION` semantics
(force rebuild). `id_rules` is new and has weaker semantics — the
cache is still readable, the file list is still accurate, only the
derived `jav_id` field may be wrong for some entries.
## Cache header shape (new)
```json
{
"cache_schema": 1,
"id_rules": 4,
"id_rules_signature": "sha256:…",
"remotes": { }
}
```
Notes:
- `cache_schema` starts at `1` for the new contract. Migration from
the legacy `version: 3` field is a one-shot read-side translation
in `load_cache()` (see Migration below).
- `id_rules` is a monotonic counter. Bump on every change to the
rules listed under "What counts as a rules change" below.
- `id_rules_signature` is a sha256 over the canonical text of the
rule definitions (regex source strings + normalizer fmts + part
detector patterns + FC2 handling toggle). It's a **belt-and-braces
check**: if a developer forgets to bump `id_rules`, the signature
catches drift. If a user has local custom rules in `config.json`,
the signature also drifts and is treated as a stale rules state.
## What counts as a rules change
Anything that influences the `jav_id` value stored in a file entry:
- `PRIMARY_ID_RE`, `COMPOUND_ID_RE`, `FALLBACK_ID_RE`,
`_NOHYPHEN_ID_RE`, `_BRACKET_ID_RE` in `rc-jav.py`
- Built-in part detectors (`BUILTIN_PART_RES`) and detection order
- FC2-PPV normalization branch
- `detect_part_from_stem` and `part_key` behavior
- `extract_id`'s overall control flow (variant-letter detection,
width-preserving padding, etc.)
- User-added normalizers from `config.json` (`id_normalizers`)
- User-added part patterns from `config.json` (`partPatterns`)
**Not** a rules change (no `id_rules` bump):
- Bug fixes to non-extraction code paths (`save_cache`, `walk_remote`,
`find_dupes`, keep-ranking logic, output formatting)
- Changes to extension-side display, since the extension never edits
cache.json
- Adding a new shared fixture case to `fixtures/`
## Behavior matrix
| User's cache | `cache_schema` | `id_rules` | Action |
|-------------------------------|-------------------|-------------------|-------------------------------------------------------------------|
| Fresh / matches both | = | = | Use as-is. |
| Schema mismatch | ≠ | (any) | **Force rebuild.** Same as today's `CACHE_VERSION` mismatch. |
| Schema match, rules stale | = | ≠ or sig drift | **Mark stale.** Use file list as-is; warn that some `jav_id`s may be out-of-date; offer "Re-extract IDs" (cheap, no remote scan). |
| Legacy `version: 3` (no new) | (translated to =) | (translated to =) | One-shot migration: replace header in place, do not force rebuild. |
"Re-extract IDs" is a new fast path: walk the existing `files[]` array
and recompute `jav_id` on each entry using the current rules. No
network or rclone call. Costs O(N) regex against N filenames, which
is seconds even for large libraries.
## Migration from `version: 3`
`load_cache()` becomes:
```python
def load_cache() -> dict:
if not CACHE_PATH.exists():
return _fresh_cache()
try:
data = json.loads(...)
except Exception:
return _fresh_cache()
# Legacy header: { "version": 3, "remotes": {...} }
# Translate in place. Treat as fresh-rules so user sees "stale" not "wipe".
if "version" in data and "cache_schema" not in data:
if data.get("version") == 3:
data = {
"cache_schema": CACHE_SCHEMA_VERSION,
"id_rules": 0, # forces "stale by rules" amber
"id_rules_signature": "legacy",
"remotes": data.get("remotes", {}),
}
else:
return _fresh_cache() # unknown legacy version → wipe
# New header validation
if data.get("cache_schema") != CACHE_SCHEMA_VERSION:
return _fresh_cache()
return data
```
Users with `version: 3` get an in-place upgrade with no rescan. The
cache shows up as "stale by rules" until they click Re-extract IDs.
## Extension UX (Cache & Scans pane)
Three states instead of today's two:
| State | Color | Pane copy | Action button |
|-------------------------|----------|------------------------------------------------------------------------------------|------------------------------|
| Fresh | green ✓ | "Cache up to date." | "Re-scan" (manual) |
| Stale by rules | amber ! | "ID extraction rules have changed since this cache was built. Some IDs may be out of date." | **"Re-extract IDs"** (fast) |
| Schema mismatch / wipe | red ✗ | "Cache version is unreadable. A full re-scan is required." | "Re-scan now" |
Background still has `cache-status` message. Response gains:
```js
{
ok: true,
cache_exists: true,
cache_schema: 1,
id_rules: 4,
id_rules_current: 4,
id_rules_match: true,
id_rules_signature_match: true,
// existing fields preserved: remotes, warnings, etc.
}
```
`renderCacheStatus` in `options-cache.js` reads these and picks the
state. Tests live in fixtures or in `options-cache.js` mocks (no need
to extend the JSON corpus for this).
## Open questions
1. **Where does the user's "id_rules_signature" come from?** The
signature must be computable from a single canonical text. Easiest:
sha256 over a sorted JSON dump of `{primary_re_source, compound_re_source,
fallback_re_source, nohyphen_re_source, bracket_re_source,
part_res_sources, fc2_handling: "enabled", user_normalizers,
user_part_patterns}`. Punt on exact shape until step 10.
2. **Should the extension trigger Re-extract IDs?** Yes —
`chrome.runtime.sendMessage({ type: "reextract-ids" })`, background
forwards to host, host calls a new `rc-jav.py --reextract` command
that walks cache.json without re-listing the remote.
3. **Per-remote tracking?** Today `id_rules` would be a single top-level
integer. Could go per-remote (`remotes[name].id_rules_at_scan`) so
"Re-extract IDs" can be triggered on a single remote. Recommend
storing per-remote and computing top-level "stale by rules" as
"any remote.id_rules_at_scan < id_rules_current". Defer detailed
design to step 10.
4. **Custom rules in config.json.** When a user adds a normalizer,
`id_rules_signature` drifts and their cache appears stale. That's
correct — their `jav_id`s really are out of date. But the global
`id_rules` integer didn't change. UI copy should distinguish
"rules updated upstream" from "your custom rules changed".
## Out of scope (step 9)
- Actually implementing the new header — that's step 10.
- Re-extract IDs CLI/host wiring — step 10.
- Bumping `cache_schema` to `1` and shipping new write code — step 10.
- Cache compaction, partial scans, incremental updates — separate work.
## Reference
- Current `CACHE_VERSION` constant: `D:\DEV\Project\rclone-jav\rc-jav.py`
line 376.
- `load_cache()` / `save_cache()` around line 416 of the same file.
- Extension consumer: `options-cache.js` `renderCacheStatus`, message
type `cache-status` in `background.js`.
- Shared fixture corpus that exercises the rule set:
`D:\DEV\Project\rclone-jav\fixtures\`.
+4
View File
@@ -0,0 +1,4 @@
{
"discord_webhook_url": "https://discord.com/api/webhooks/1507933272158507200/TEDqaLNBQn4dlSsG5kC2HP9IbTPg0trVWcy1WA46TdaLfZ1waMV82nTcNqVsfOY1Sw6u",
"pc_label": "001x100"
}
+6
View File
@@ -0,0 +1,6 @@
{
"allowed_extension_ids": {
"rclone-jav": "dklpnjdfcoalaognbgbjoilklfjlnpnj",
"tabvault": "fdeddmkchldohogpogpahnkibifciflp"
}
}
+2 -1
View File
@@ -4,6 +4,7 @@
"path": "D:\\DEV\\Extensions\\Production\\rclone-jav\\host\\rcjav-host.bat", "path": "D:\\DEV\\Extensions\\Production\\rclone-jav\\host\\rcjav-host.bat",
"type": "stdio", "type": "stdio",
"allowed_origins": [ "allowed_origins": [
"chrome-extension://afbnfamppannbmhgphbbgdkmilijfagp/" "chrome-extension://dklpnjdfcoalaognbgbjoilklfjlnpnj/",
"chrome-extension://fdeddmkchldohogpogpahnkibifciflp/"
] ]
} }
+54 -22
View File
@@ -1,15 +1,15 @@
# install-host.ps1 # install-host.ps1
# Registers rclonex native-messaging host so Brave can launch it. # Registers rclonex native-messaging host so Brave can launch it.
# #
# Usage: .\install-host.ps1 -ExtensionId <id-from-brave://extensions> # Usage: .\install-host.ps1
# Optional: .\install-host.ps1 -ExtensionId <id-from-brave://extensions>
# #
# Writes manifest to host\com.rcjav.host.json with the correct path + extension ID baked in, # Writes manifest to host\com.rcjav.host.json with the correct path + extension ID allowed,
# then registers it in HKLM (requires admin - script self-elevates if needed) AND HKCU. # 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. # HKLM is required on some Brave installs; HKCU alone is not always honored.
param( param(
[Parameter(Mandatory = $true)] [string]$ExtensionId = ""
[string]$ExtensionId
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -19,30 +19,62 @@ $batPath = Join-Path $hostDir "rcjav-host.bat"
if (-not (Test-Path $batPath)) { throw "Host bat not found: $batPath" } if (-not (Test-Path $batPath)) { throw "Host bat not found: $batPath" }
$manifestPath = Join-Path $hostDir "com.rcjav.host.json" $manifestPath = Join-Path $hostDir "com.rcjav.host.json"
$template = Join-Path $hostDir "com.rcjav.host.json.template" $allowlistPath = Join-Path $hostDir "allowed-extension-ids.json"
if (-not (Test-Path $template)) { throw "Template not found: $template" }
$content = Get-Content $template -Raw # Self-elevate before writing the manifest or HKLM registry entries. Some
$content = $content.Replace("__HOST_BAT__", ($batPath -replace "\\", "\\")) # installs keep the host folder under admin-owned permissions.
$content = $content.Replace("__EXTENSION_ID__", $ExtensionId) $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. # UTF-8 WITHOUT BOM - Chrome/Brave rejects manifests with a BOM.
[System.IO.File]::WriteAllText($manifestPath, $content, [System.Text.UTF8Encoding]::new($false)) [System.IO.File]::WriteAllText($manifestPath, $content, [System.Text.UTF8Encoding]::new($false))
Write-Host "Manifest written: $manifestPath" 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. # Register in HKLM - required on some Brave installs.
$keys = @( $keys = @(
'HKLM:\Software\BraveSoftware\Brave-Browser\NativeMessagingHosts\com.rcjav.host', 'HKLM:\Software\BraveSoftware\Brave-Browser\NativeMessagingHosts\com.rcjav.host',
+8 -4
View File
@@ -1,8 +1,12 @@
@echo off @echo off
REM Portable: uses Windows py launcher if present, falls back to python on PATH. REM Portable: use python on PATH. Avoid py.exe as an extra native-messaging
REM Stderr redirected to log file so it can't pollute stdout (native messaging is binary). 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 setlocal
set "PYBIN=python" set "PYBIN=python"
where py >nul 2>&1 && set "PYBIN=py"
if not exist "%~dp0logs" mkdir "%~dp0logs" if not exist "%~dp0logs" mkdir "%~dp0logs"
"%PYBIN%" -u "%~dp0rcjav-host.py" 2>>"%~dp0logs\rcjav-host-stderr.log" "%PYBIN%" -u "%~dp0rcjav-host.py"
+905 -62
View File
File diff suppressed because it is too large Load Diff
+2 -9
View File
@@ -1,17 +1,10 @@
@echo off @echo off
REM Double-click this to register the native messaging host with Brave. 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 setlocal
set /p EXT_ID="Paste the rclone-jav extension ID from brave://extensions: "
if "%EXT_ID%"=="" ( powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0install-host.ps1"
echo No ID entered. Aborting.
pause
exit /b 1
)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0install-host.ps1" -ExtensionId "%EXT_ID%"
echo. echo.
echo Done. Press any key to close. echo Done. Press any key to close.
pause >nul pause >nul
+27 -8
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "rclone-jav", "name": "rclone-jav",
"version": "0.1.0", "version": "0.1.53",
"description": "Check current page title against your rc-jav library via native messaging.", "description": "Check current page title against your rc-jav library via native messaging.",
"permissions": [ "permissions": [
"nativeMessaging", "nativeMessaging",
@@ -11,12 +11,14 @@
"activeTab", "activeTab",
"scripting" "scripting"
], ],
"host_permissions": ["<all_urls>"], "host_permissions": [
"<all_urls>"
],
"background": { "background": {
"service_worker": "background.js" "service_worker": "background.js"
}, },
"action": { "action": {
"default_popup": "popup.html", "default_popup": "src/popup/popup.html",
"default_title": "rclone-jav — check page", "default_title": "rclone-jav — check page",
"default_icon": { "default_icon": {
"32": "icons/icon-32.png", "32": "icons/icon-32.png",
@@ -27,18 +29,35 @@
"32": "icons/icon-32.png", "32": "icons/icon-32.png",
"128": "icons/icon-128.png" "128": "icons/icon-128.png"
}, },
"options_page": "options.html", "options_page": "src/options/options.html",
"web_accessible_resources": [
{
"resources": [
"src/options/options.html",
"src/bulk-check/bulk-check.html"
],
"matches": ["<all_urls>"]
}
],
"content_scripts": [ "content_scripts": [
{ {
"matches": ["<all_urls>"], "matches": [
"js": ["content.js"], "<all_urls>"
],
"js": [
"src/shared/id-extract.js",
"content.js"
],
"run_at": "document_idle" "run_at": "document_idle"
} }
], ],
"commands": { "commands": {
"check-current-page": { "check-current-page": {
"suggested_key": { "default": "Alt+J" }, "suggested_key": {
"default": "Alt+J"
},
"description": "rclone-jav: check current page title" "description": "rclone-jav: check current page title"
} }
} },
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAycbrL/el9uedSjN0pXQtp67tSJNc9ueL1QgSwgpo74k0d8tJuQdsGW9XIqaSV4vSlAZSa7AoTEgfMa7od1QniNrH5vlpp9YJoueCRx6GiXtEqrHT5Qdh7sjHEqOtjUTko58MUAGYSEjyGFPnranH49YOrXOrAYHGCRv+VWEA0ZA9A8SUZdJrcteUs9s4KNkZWtsQeSL6QvvZbnAvZZJgAAM1puUjNRAfc+uEwHRWe4RlObGOGS8mPdjvo+7YIKLOROrxIwtc3HkBIppTDyeywNkLcXvJH7L1QYTCdKCBTvisN3367XQdqFYTpPat2wa17z0OI+JYMaBoox4TAh3inQIDAQAB"
} }
-2356
View File
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
body {
font-family: -apple-system, Segoe UI, sans-serif;
font-size: 13px;
margin: 0;
padding: 12px;
background: #1a1a1a;
color: #ddd;
}
#header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
#header strong { font-size: 15px; }
#mode-pill {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.4px;
background: #1a2a3a;
color: #6ec1ff;
padding: 2px 8px;
border-radius: 3px;
border: 1px solid #2a4a6a;
}
#mode-pill[data-mode="CACHE"] {
background: #1a3a1a;
color: #afa;
border-color: #2a5a2a;
}
.help {
font-size: 12px;
color: #aaa;
line-height: 1.4;
margin-bottom: 8px;
}
.help code {
background: #0d0d0d;
padding: 1px 4px;
border-radius: 2px;
font-family: Consolas, monospace;
color: #ddd;
}
.dim { color: #777; }
textarea#bulk-id-input {
width: 100%;
height: 140px;
resize: vertical;
background: #0d0d0d;
color: #ddd;
border: 1px solid #444;
border-radius: 3px;
padding: 6px 8px;
font-size: 12px;
font-family: Consolas, monospace;
box-sizing: border-box;
}
textarea#bulk-id-input:focus { outline: none; border-color: #6ec1ff; }
.button-row {
display: flex;
align-items: center;
gap: 6px;
margin: 8px 0;
}
button {
background: #333;
color: #ddd;
border: 1px solid #555;
border-radius: 3px;
padding: 4px 10px;
cursor: pointer;
font-size: 12px;
}
button:hover { background: #444; }
button:disabled { opacity: 0.5; cursor: default; }
button.running { background: #3a1a1a; border-color: #722; color: #faa; }
#count-pill {
margin-left: auto;
font-size: 11px;
}
.mono-output {
background: #0d0d0d;
border: 1px solid #333;
border-radius: 3px;
padding: 8px 10px;
font-family: Consolas, monospace;
font-size: 12px;
line-height: 1.5;
max-height: 260px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
.mono-output:empty { display: none; }
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Bulk ID Check — rclone-jav</title>
<link rel="stylesheet" href="bulk-check.css">
</head>
<body>
<div id="header">
<strong>Bulk ID Check</strong>
<span id="mode-pill" title="Current search mode"></span>
</div>
<div class="help">
Paste IDs separated by lines, commas, or spaces. Uses the current LIVE/CACHE mode and active library profile.
<br><span class="dim">Examples: <code>BLK-474</code>, <code>FC2-4865786</code>, <code>PRTD-[027-030]</code></span>
</div>
<textarea id="bulk-id-input" spellcheck="false" autocomplete="off"
placeholder="BLK-474&#10;FC2-4865786&#10;PRTD-[027-030]"></textarea>
<div class="button-row">
<button id="bulk-id-run" type="button">Check IDs</button>
<button id="bulk-id-clear" type="button">Clear</button>
<span id="count-pill" class="dim"></span>
</div>
<div id="bulk-id-results" class="mono-output"></div>
<script src="bulk-check.js"></script>
</body>
</html>
+133
View File
@@ -0,0 +1,133 @@
// Standalone Bulk ID Check window.
// Launched via chrome.windows.create from popup; background.js owns
// the windowId lifecycle (chrome.storage.session.bulkCheckWindowId).
const LAST_PASTE_KEY = "bulkCheckLastPaste";
const SAVE_DEBOUNCE_MS = 500;
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function readBulkIds() {
return [...new Set(document.getElementById("bulk-id-input").value
.split(/[\s,]+/)
.map((x) => x.trim())
.filter(Boolean))];
}
function updateCountPill() {
const ids = readBulkIds();
const pill = document.getElementById("count-pill");
pill.textContent = ids.length ? `${ids.length} unique ID${ids.length === 1 ? "" : "s"}` : "";
}
function renderBulkResults(r) {
const out = document.getElementById("bulk-id-results");
if (!r || !r.ok) {
out.innerHTML = `<span style="color:#faa;">error:</span> ${escapeHtml(r?.error || "no response")}`;
return;
}
const rows = [
`<div><span style="color:#777;">Mode:</span> ${escapeHtml(r.search_mode || "?")} · <span style="color:#777;">Queries:</span> ${escapeHtml(r.query_count || 0)} · <span style="color:#777;">Hits:</span> ${escapeHtml(r.hits || 0)} · <span style="color:#777;">Host:</span> ${escapeHtml(r.timings?.host_rcjav_ms ?? "?")}ms</div>`,
];
for (const q of r.queries || []) {
const hit = q.hits > 0;
const sample = (q.structured || []).slice(0, 3).map((h) => h.full_path || h.path || h.jav_id).join(" | ");
rows.push(`<div style="margin-top:7px;">
<span style="color:${hit ? "#afa" : "#ffa"};font-weight:600;">${hit ? "HIT" : "MISS"}</span>
<span>${escapeHtml(q.query || "?")}</span> · ${escapeHtml(q.hits || 0)} hit(s)
${sample ? `<div style="color:#777;margin-left:12px;">${escapeHtml(sample)}</div>` : `<div style="color:#777;margin-left:12px;">${escapeHtml(q.no_match_title || "No library hit")}</div>`}
</div>`);
}
out.innerHTML = rows.join("");
}
async function refreshModePill() {
const pill = document.getElementById("mode-pill");
try {
const settings = await chrome.runtime.sendMessage({ type: "get-settings" });
const mode = settings?.quickMode !== false ? "LIVE" : "CACHE";
pill.textContent = mode;
pill.dataset.mode = mode;
} catch {
pill.textContent = "?";
}
}
let _saveTimer = null;
function schedulePersist() {
if (_saveTimer) clearTimeout(_saveTimer);
_saveTimer = setTimeout(async () => {
_saveTimer = null;
const value = document.getElementById("bulk-id-input").value;
try {
await chrome.storage.local.set({ [LAST_PASTE_KEY]: value });
} catch { /* ignore */ }
}, SAVE_DEBOUNCE_MS);
}
async function restoreLastPaste() {
try {
const stored = await chrome.storage.local.get(LAST_PASTE_KEY);
const value = stored?.[LAST_PASTE_KEY];
if (typeof value === "string" && value) {
document.getElementById("bulk-id-input").value = value;
updateCountPill();
}
} catch { /* ignore */ }
}
document.getElementById("bulk-id-input").addEventListener("input", () => {
updateCountPill();
schedulePersist();
});
document.getElementById("bulk-id-clear").addEventListener("click", () => {
document.getElementById("bulk-id-input").value = "";
document.getElementById("bulk-id-results").innerHTML = "";
updateCountPill();
schedulePersist();
});
document.getElementById("bulk-id-run").addEventListener("click", async () => {
const runBtn = document.getElementById("bulk-id-run");
const out = document.getElementById("bulk-id-results");
const queries = readBulkIds();
if (!queries.length) {
out.innerHTML = `<span style="color:#ffa;">paste at least one ID</span>`;
return;
}
runBtn.disabled = true;
runBtn.classList.add("running");
out.textContent = `checking ${queries.length} ID(s)...`;
try {
const settings = await chrome.runtime.sendMessage({ type: "get-settings" });
const quick = settings?.quickMode !== false;
const r = await chrome.runtime.sendMessage({
type: "bulk-query",
queries,
quick,
});
renderBulkResults(r);
} catch (err) {
out.innerHTML = `<span style="color:#faa;">error:</span> ${escapeHtml(err?.message || String(err))}`;
} finally {
runBtn.disabled = false;
runBtn.classList.remove("running");
}
});
// Ctrl/Cmd+Enter inside textarea = run
document.getElementById("bulk-id-input").addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
document.getElementById("bulk-id-run").click();
}
});
(async () => {
await restoreLastPaste();
await refreshModePill();
})();
@@ -107,6 +107,35 @@ document.getElementById("setup-health-run").addEventListener("click", (event) =>
}) })
); );
// Three-state UX (docs/CACHE_CONTRACT.md): fresh / stale_by_rules / schema_mismatch.
// Renders an inline banner above the per-remote list. Stale_by_rules adds a
// "Re-extract IDs" button that triggers the fast rebuild without rclone.
function renderCacheContractBanner(r) {
const state = r.cache_state;
if (r.rules_info_error) {
return `<div style="margin-top:10px;padding:6px 8px;background:rgba(255,200,50,.08);border:1px solid rgba(255,200,50,.25);border-radius:4px;color:#ffa;">⚠ rules lookup failed: ${escapeHtml(r.rules_info_error)}</div>`;
}
if (state === "fresh") {
return `<div style="margin-top:10px;padding:6px 8px;background:rgba(120,200,120,.08);border:1px solid rgba(120,200,120,.25);border-radius:4px;color:#afa;">✓ Cache up to date with current ID rules.</div>`;
}
if (state === "stale_by_rules") {
const sigLine = r.id_rules_signature && r.id_rules_signature !== "legacy"
? `<div style="color:#999;font-size:11px;margin-top:3px;">Cache signature: <code>${escapeHtml(String(r.id_rules_signature).slice(0, 22))}…</code></div>`
: `<div style="color:#999;font-size:11px;margin-top:3px;">Cache predates the two-tier contract (legacy header).</div>`;
return `<div style="margin-top:10px;padding:8px 10px;background:rgba(255,200,50,.08);border:1px solid rgba(255,200,50,.3);border-radius:4px;color:#ffa;">
! <strong>Cache is stale by rules.</strong> ID extraction rules have changed since this cache was built. Some <code>jav_id</code> values may be out of date.
${sigLine}
<div style="margin-top:8px;"><button class="chip-btn cache-reextract" type="button" style="color:#ffd97a;background:rgba(255,200,50,.12);border-color:rgba(255,200,50,.35);font-weight:600;">Re-extract IDs (fast, no rescan)</button></div>
</div>`;
}
if (state === "schema_mismatch") {
return `<div style="margin-top:10px;padding:8px 10px;background:rgba(255,120,120,.08);border:1px solid rgba(255,120,120,.3);border-radius:4px;color:#faa;">
<strong>Cache schema mismatch.</strong> The on-disk cache shape is incompatible (schema ${escapeHtml(r.cache_schema ?? "?")} vs expected ${escapeHtml(r.expected_cache_schema ?? "?")}). A full re-scan is required.
</div>`;
}
return "";
}
document.getElementById("cache-status-run").addEventListener("click", async () => { document.getElementById("cache-status-run").addEventListener("click", async () => {
const out = document.getElementById("cache-status-results"); const out = document.getElementById("cache-status-results");
out.textContent = "checking cache..."; out.textContent = "checking cache...";
@@ -118,6 +147,16 @@ document.getElementById("cache-status-run").addEventListener("click", async () =
} }
rememberConfiguredScanRoots(r); rememberConfiguredScanRoots(r);
_cacheSkippedByRemote = new Map((r.remotes || []).map((m) => [m.remote, m.skipped_items || []])); _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) { if (!r.cache_exists) {
const configured = (r.remotes || []).map((m) => const configured = (r.remotes || []).map((m) =>
`<div style="margin-top:6px;color:#ffa;">! ${escapeHtml(m.remote)} · never scanned</div>` `<div style="margin-top:6px;color:#ffa;">! ${escapeHtml(m.remote)} · never scanned</div>`
@@ -136,6 +175,7 @@ document.getElementById("cache-status-run").addEventListener("click", async () =
`<div><span style="color:#777;">Configured target:</span> ${escapeHtml((r.configured?.default_target || []).join(", ") || "(none)")}</div>`, `<div><span style="color:#777;">Configured target:</span> ${escapeHtml((r.configured?.default_target || []).join(", ") || "(none)")}</div>`,
`<div><span style="color:#777;">Configured source:</span> ${escapeHtml((r.configured?.default_source || []).join(", ") || "(none)")}</div>`, `<div><span style="color:#777;">Configured source:</span> ${escapeHtml((r.configured?.default_source || []).join(", ") || "(none)")}</div>`,
]; ];
rows.push(renderCacheContractBanner(r));
for (const m of r.remotes || []) { for (const m of r.remotes || []) {
const color = m.status === "never_scanned" || m.stale ? "#ffa" : "#afa"; const color = m.status === "never_scanned" || m.stale ? "#ffa" : "#afa";
const state = m.status === "never_scanned" ? "never scanned" : `${m.status || (m.stale ? "stale" : "fresh")} · age ${fmtCacheAge(m.age_hours)}`; const state = m.status === "never_scanned" ? "never scanned" : `${m.status || (m.stale ? "stale" : "fresh")} · age ${fmtCacheAge(m.age_hours)}`;
+343
View File
@@ -0,0 +1,343 @@
// ---------- diagnostics ----------
// Extension ID display + copy button (added when Transfer Assistant was deleted).
// Diagnostics is the canonical home for "what's my extension ID?" info now.
(() => {
const idEl = document.getElementById("diag-extension-id");
const copyBtn = document.getElementById("diag-copy-extension-id");
if (idEl) idEl.textContent = chrome.runtime.id;
if (copyBtn) {
copyBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(chrome.runtime.id);
copyBtn.textContent = "Copied";
setTimeout(() => { copyBtn.textContent = "Copy ID"; }, 1200);
} catch (_) {
copyBtn.textContent = "Copy failed";
setTimeout(() => { copyBtn.textContent = "Copy ID"; }, 1200);
}
});
}
})();
document.getElementById("run-diag").addEventListener("click", (event) =>
keepActionViewport(event.currentTarget, runDiagnostics)
);
// ---------- native messaging RPC log ----------
const NATIVE_LOG_KEY = "rclonejavNativeLog";
function _fmtNativeLogTime(ts) {
if (!Number.isFinite(ts)) return "?";
const d = new Date(ts);
const pad = (n) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, "0")}`;
}
function _fmtBytes(n) {
if (!Number.isFinite(n) || n < 0) return "?";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
return `${(n / 1024 / 1024).toFixed(2)} MiB`;
}
async function renderNativeLog() {
const out = document.getElementById("native-log-results");
if (!out) return;
const errOnly = document.getElementById("native-log-errors-only")?.checked;
let entries = [];
try {
const got = await chrome.storage.local.get(NATIVE_LOG_KEY);
entries = Array.isArray(got[NATIVE_LOG_KEY]) ? got[NATIVE_LOG_KEY] : [];
} catch (e) {
out.innerHTML = `<span style="color:#faa;">error reading log:</span> ${escapeHtml(e.message || String(e))}`;
return;
}
if (errOnly) entries = entries.filter((e) => !e.ok);
if (!entries.length) {
out.innerHTML = `<span style="color:#777;">${errOnly ? "no errors recorded" : "no RPC calls recorded yet"}</span>`;
return;
}
out.innerHTML = entries.slice(0, 80).map((e) => {
const ok = !!e.ok;
const color = ok ? "#9be3b3" : "#ff9097";
const action = e.action || "?";
const latency = Number.isFinite(e.latency_ms) ? `${e.latency_ms}ms` : "?";
const size = e.resp_bytes != null ? ` · ${_fmtBytes(e.resp_bytes)}` : "";
const truncated = e.truncated ? ` · <span style="color:#ffd784;">TRUNCATED${e.truncated_reason ? " (" + escapeHtml(e.truncated_reason) + ")" : ""}</span>` : "";
const inflight = e.inflight != null ? ` · ${e.inflight} inflight` : "";
const head = `<div><span style="color:#888;">${escapeHtml(_fmtNativeLogTime(e.ts))}</span> <span style="color:${color};">${ok ? "✓" : "✗"} ${escapeHtml(action)}</span> · ${escapeHtml(latency)}${size}${truncated}${inflight}</div>`;
const tail = !ok
? `<div style="color:#aaa;margin-left:14px;"><span style="color:#888;">${escapeHtml(e.error_kind || "error")}:</span> ${escapeHtml(e.error || "")}</div>`
: "";
return `<div class="activity-entry">${head}${tail}</div>`;
}).join("");
}
document.getElementById("native-log-run")?.addEventListener("click", renderNativeLog);
document.getElementById("native-log-errors-only")?.addEventListener("change", renderNativeLog);
document.getElementById("native-log-clear")?.addEventListener("click", async () => {
if (!confirm("Clear extension-side native messaging log?")) return;
await chrome.storage.local.remove(NATIVE_LOG_KEY);
renderNativeLog();
});
document.getElementById("host-events-clear")?.addEventListener("click", async (e) => {
if (!confirm("Truncate host/logs/rcjav-host-events.log on disk?")) return;
const btn = e.currentTarget;
const original = btn.textContent;
btn.disabled = true;
btn.textContent = "Clearing…";
try {
const r = await chrome.runtime.sendMessage({ type: "clear-events-log" });
btn.textContent = r?.ok ? "Cleared" : `Failed: ${r?.error || "no response"}`;
} catch (err) {
btn.textContent = `Failed: ${err.message || err}`;
} finally {
setTimeout(() => { btn.disabled = false; btn.textContent = original; }, 1500);
}
});
// Live update if SW writes new entries while the page is open.
chrome.storage.onChanged.addListener((changes, area) => {
if (area === "local" && NATIVE_LOG_KEY in changes) renderNativeLog();
});
document.getElementById("host-status-run").addEventListener("click", (event) =>
keepActionViewport(event.currentTarget, runHostStatus)
);
document.getElementById("host-repair-run").addEventListener("click", (event) =>
keepActionViewport(event.currentTarget, runHostRepair)
);
document.getElementById("host-verify-run").addEventListener("click", (event) =>
keepActionViewport(event.currentTarget, runHostStatus)
);
document.getElementById("run-all-diag").addEventListener("click", (event) =>
keepActionViewport(event.currentTarget, async () => {
clearNativeRepairCard();
const runtime = await runDiagnostics();
if (runtime && runtime.nativeBlocked) {
renderBlockedByNativeIssue(document.getElementById("host-status-results"), "Host registration");
return;
}
await runHostStatus();
})
);
function renderDiagRows(out, checks, emptyLabel) {
out.innerHTML = "";
if (!checks || checks.length === 0) {
out.innerHTML = `<div class="diag-row warn"><span class="icon">!</span><span class="name">${escapeHtml(emptyLabel)}</span><span class="detail">no checks returned</span></div>`;
return;
}
const counts = checks.reduce((acc, c) => {
const status = c.status || "warn";
acc[status] = (acc[status] || 0) + 1;
return acc;
}, {});
const summary = document.createElement("div");
summary.className = "diag-row " + ((counts.fail || 0) ? "fail" : (counts.warn || 0) ? "warn" : "ok");
summary.innerHTML = `<span class="icon">#</span><span class="name">summary</span><span class="detail">${checks.length} checks · ok ${counts.ok || 0} · info ${counts.info || 0} · warn ${counts.warn || 0} · fail ${counts.fail || 0}</span>`;
out.appendChild(summary);
for (const c of checks) {
const row = document.createElement("div");
row.className = "diag-row " + (c.status || "warn");
const status = c.status || "warn";
const icon = status === "ok" ? "✓" : status === "info" ? "i" : status === "warn" ? "!" : "✗";
row.innerHTML = `<span class="icon">${icon}</span><span class="name">${escapeHtml(c.name)}</span><span class="detail">${formatDiagDetail(c.detail || "")}</span>`;
out.appendChild(row);
}
}
function formatDiagDetail(detail) {
const text = String(detail || "");
if (!text) return "";
const shouldCollapse = text.length > 120 || text.includes("\n") || (text.match(/[;|]/g) || []).length > 2;
if (!shouldCollapse) return escapeHtml(text);
const first = text.split(/\r?\n/)[0].slice(0, 110);
return `<details><summary>${escapeHtml(first)}${text.length > first.length ? "…" : ""}</summary><pre>${escapeHtml(text)}</pre></details>`;
}
async function runDiagnostics() {
const out = document.getElementById("diag-results");
clearNativeRepairCard();
out.innerHTML = '<div class="diag-row warn"><span class="icon">…</span><span class="name">running…</span><span class="detail">waiting for native host</span></div>';
try {
const r = await chrome.runtime.sendMessage({ type: "diagnostics" });
if (!r || !r.ok) {
await renderNativeMessagingFailure(r);
renderBlockedByNativeIssue(out, "Runtime diagnostics");
return { nativeBlocked: true };
}
renderDiagRows(out, r.checks || [], "runtime");
return { ok: true };
} catch (err) {
out.innerHTML = `<div class="diag-row fail"><span class="icon">✗</span><span class="name">runtime</span><span class="detail">${escapeHtml(err.message || String(err))}</span></div>`;
return { ok: false };
}
}
async function runHostStatus() {
const out = document.getElementById("host-status-results");
clearNativeRepairCard();
out.innerHTML = '<div class="diag-row warn"><span class="icon">…</span><span class="name">checking…</span><span class="detail">reading manifest and registry state</span></div>';
try {
const r = await chrome.runtime.sendMessage({ type: "host-status" });
if (!r || !r.ok) {
await renderNativeMessagingFailure(r);
renderBlockedByNativeIssue(out, "Native host checks");
return { nativeBlocked: true };
}
renderDiagRows(out, r.checks || [], "host status");
return { ok: true };
} catch (err) {
out.innerHTML = `<div class="diag-row fail"><span class="icon">✗</span><span class="name">host status</span><span class="detail">${escapeHtml(err.message || String(err))}</span></div>`;
return { ok: false };
}
}
async function runHostRepair() {
const out = document.getElementById("host-status-results");
clearNativeRepairCard();
out.innerHTML = '<div class="diag-row warn"><span class="icon">…</span><span class="name">repairing…</span><span class="detail">launching install-host.ps1 (UAC prompt will appear)</span></div>';
try {
const r = await chrome.runtime.sendMessage({ type: "repair-host" });
if (!r || !r.ok) {
if (r?.error_kind) {
await renderNativeMessagingFailure(r);
renderBlockedByNativeIssue(out, "Registration repair");
} else {
out.innerHTML = `<div class="diag-row fail"><span class="icon">✗</span><span class="name">Registration repair</span><span class="detail">${escapeHtml(r?.error || "repair failed")}</span></div>`;
}
return { ok: false };
}
out.innerHTML = "";
renderCompletedNativeRepair(r);
return { ok: true };
} catch (err) {
out.innerHTML = `<div class="diag-row fail"><span class="icon">✗</span><span class="name">Registration repair</span><span class="detail">${escapeHtml(err.message || String(err))}</span></div>`;
return { ok: false };
}
}
function clearNativeRepairCard() {
const card = document.getElementById("native-repair-card");
const out = document.getElementById("native-repair-results");
const title = document.getElementById("native-repair-title");
if (card) card.style.display = "none";
if (out) out.innerHTML = "";
if (title) title.textContent = "Native host setup";
}
function renderCompletedNativeRepair(response) {
const card = document.getElementById("native-repair-card");
const out = document.getElementById("native-repair-results");
if (!card || !out) return;
card.style.display = "";
const title = document.getElementById("native-repair-title");
if (title) title.textContent = "install-host.ps1 launched";
out.innerHTML = `
<div class="diag-row ok"><span class="icon">✓</span><span class="name">Launcher started</span><span class="detail">${escapeHtml(response.message || "install-host.ps1 launched")}</span></div>
<div class="diag-row info"><span class="icon">i</span><span class="name">Script</span><span class="detail">${escapeHtml(response.script_path || "")}</span></div>
<div class="diag-row info"><span class="icon">i</span><span class="name">Manifest target</span><span class="detail">${escapeHtml(response.manifest_path || "")}</span></div>
<div class="diag-row warn"><span class="icon">!</span><span class="name">Next steps</span><span class="detail">1) Approve the UAC prompt. 2) Wait for the PowerShell window to print "Press Enter to close" and press Enter. 3) Fully close and reopen Brave. 4) Click Verify Registration.</span></div>
`;
}
function renderBlockedByNativeIssue(out, title) {
out.innerHTML = `<div class="diag-row info"><span class="icon">i</span><span class="name">${escapeHtml(title)}</span><span class="detail">Blocked until this PC registers the native host for the current extension ID. Use the setup card above.</span></div>`;
}
async function getPackagedHostPaths() {
try {
const resp = await fetch(chrome.runtime.getURL("host/com.rcjav.host.json"));
if (!resp.ok) return {};
const manifest = await resp.json();
const bat = manifest.path || "";
const hostDir = bat.replace(/[\\/][^\\/]+$/, "");
return {
hostBat: bat,
hostDir,
registerBat: hostDir ? hostDir + "\\register-host.bat" : "",
installPs1: hostDir ? hostDir + "\\install-host.ps1" : "",
};
} catch {
return {};
}
}
async function renderNativeMessagingFailure(response) {
const card = document.getElementById("native-repair-card");
const out = document.getElementById("native-repair-results");
if (!card || !out) return;
card.style.display = "";
const title = document.getElementById("native-repair-title");
if (title) title.textContent = "Register host on this PC";
const error = response?.error || "no response";
const kind = response?.error_kind || (/forbidden/i.test(error) ? "forbidden" : "unknown");
const extensionId = response?.extension_id || chrome.runtime.id;
const paths = await getPackagedHostPaths();
const installCommand = paths.installPs1
? `pwsh -ExecutionPolicy Bypass -File "${paths.installPs1}"`
: `pwsh -ExecutionPolicy Bypass -File ".\\host\\install-host.ps1"`;
const registerCommand = paths.registerBat ? `"${paths.registerBat}"` : ".\\host\\register-host.bat";
const hostDir = paths.hostDir || "";
const hostFolderUrl = hostDir ? "file:///" + hostDir.replace(/\\/g, "/").replace(/^([A-Za-z]:)/, "$1") : "";
let cause = "This extension cannot launch the native messaging host yet.";
let fix = "Run register-host.bat once on this PC, fully restart Brave, then verify registration.";
if (kind === "forbidden") {
cause = "Brave found the native host but the extension ID is not in its allowlist on this PC.";
fix = "Run register-host.bat to refresh the manifest from allowed-extension-ids.json.";
} else if (kind === "not_found") {
cause = "Brave could not find a registered native messaging host for com.rcjav.host on this PC.";
fix = "Run register-host.bat from the extension host folder.";
} else if (kind === "disconnected") {
cause = "The native host started and then disconnected or crashed.";
fix = "After registration is fixed, run Runtime diagnostics again to check Python, rc-jav, and rclone.";
} else if (kind === "timeout") {
cause = "The native host did not respond before the timeout.";
fix = "Restart Brave and check whether a scan or rclone command is stuck.";
}
const openFolderBtn = hostFolderUrl
? `<button type="button" data-open-folder="${escapeHtml(hostFolderUrl)}" data-folder-path="${escapeHtml(hostDir)}">Open Host Folder</button>`
: "";
out.innerHTML = `
<div class="diag-row warn"><span class="icon">!</span><span class="name">Setup required</span><span class="detail">Native host registration must be fixed before cache, runtime, and host checks can run.</span></div>
<div class="diag-row warn"><span class="icon">!</span><span class="name">Likely cause</span><span class="detail">${escapeHtml(cause)}</span></div>
<div class="diag-row info"><span class="icon">i</span><span class="name">Host message</span><span class="detail">${escapeHtml(error)}</span></div>
<div class="diag-row ok"><span class="icon">→</span><span class="name">Fix on this PC</span><span class="detail">${escapeHtml(fix)}</span></div>
<div class="diag-row info"><span class="icon">1</span><span class="name">Run register-host</span><span class="detail">
<details open><summary>${escapeHtml(registerCommand)}</summary><pre>${escapeHtml(`Double-click ${registerCommand}\nor run the PowerShell alternative:\n${installCommand}\n\nThe script reads the extension ID from allowed-extension-ids.json — no paste step.`)}</pre></details>
<span class="diag-action">${openFolderBtn}<button type="button" data-copy="${escapeHtml(hostDir)}" data-copy-label="Copy Folder Path">Copy Folder Path</button><button type="button" data-copy="${escapeHtml(registerCommand)}" data-copy-label="Copy Script Path">Copy Script Path</button><button type="button" data-copy="${escapeHtml(installCommand)}" data-copy-label="Copy PowerShell Alternative">Copy PowerShell Alternative</button></span>
</span></div>
<div class="diag-row info"><span class="icon">2</span><span class="name">Restart Brave</span><span class="detail">Close every Brave window/process, reopen Brave, then reload the extension.</span></div>
<div class="diag-row info"><span class="icon">3</span><span class="name">Verify</span><span class="detail"><span class="diag-action"><button type="button" data-verify-registration>Verify Registration</button></span></span></div>
`;
for (const btn of out.querySelectorAll("button[data-copy]")) {
btn.addEventListener("click", async () => {
await navigator.clipboard.writeText(btn.dataset.copy || "");
btn.textContent = "Copied";
setTimeout(() => { btn.textContent = btn.dataset.copyLabel || "Copy"; }, 1200);
});
}
for (const btn of out.querySelectorAll("button[data-open-folder]")) {
btn.addEventListener("click", async () => {
const url = btn.dataset.openFolder;
const folderPath = btn.dataset.folderPath || "";
try {
// file:// URLs require "Allow access to file URLs" toggled on for the
// extension. If Brave silently blocks it (no tab opens), fall back to
// clipboard so the user can paste into File Explorer (Win+E).
await chrome.tabs.create({ url });
btn.textContent = "Opening…";
setTimeout(() => { btn.textContent = "Open Host Folder"; }, 1500);
} catch (_) {
try { await navigator.clipboard.writeText(folderPath); } catch {}
btn.textContent = "Blocked — path copied";
setTimeout(() => { btn.textContent = "Open Host Folder"; }, 2500);
}
});
}
for (const btn of out.querySelectorAll("button[data-verify-registration]")) {
btn.addEventListener("click", runHostStatus);
}
}
@@ -116,6 +116,7 @@ function renderDupeReview(r) {
return; return;
} }
lastDupeReview = r; lastDupeReview = r;
try { chrome.storage.local.set({ badge_dupe_count: Number(r.group_count) || 0 }); } catch {}
exportBtn.disabled = false; exportBtn.disabled = false;
_drActiveFmt = "all"; _drActiveFmt = "all";
_drActiveRes = "all"; _drActiveRes = "all";
@@ -558,9 +559,18 @@ document.getElementById("kr-vip-add")?.addEventListener("keydown", (event) => {
}); });
async function loadKeepRanking() { 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 { try {
const r = await chrome.runtime.sendMessage({ type: "get-keep-ranking" }); 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 ranking = r.keep_ranking || {};
const toleranceEl = document.getElementById("kr-tolerance"); const toleranceEl = document.getElementById("kr-tolerance");
const resTagEl = document.getElementById("kr-res-tag"); const resTagEl = document.getElementById("kr-res-tag");
@@ -568,12 +578,14 @@ async function loadKeepRanking() {
if (toleranceEl) toleranceEl.value = ranking.size_tolerance_mib ?? 0; if (toleranceEl) toleranceEl.value = ranking.size_tolerance_mib ?? 0;
if (resTagEl) resTagEl.checked = ranking.tiebreak_res_tag !== false; if (resTagEl) resTagEl.checked = ranking.tiebreak_res_tag !== false;
if (longerNameEl) longerNameEl.checked = ranking.tiebreak_longer_name !== false; if (longerNameEl) longerNameEl.checked = ranking.tiebreak_longer_name !== false;
_krRenderVipList(ranking.priority_folders || KR_DEFAULT_VIP_FOLDERS); const fmts = Array.isArray(ranking.format_preference) && ranking.format_preference.length
_krRenderFmtList(ranking.format_preference || KR_DEFAULT_FMTS); ? 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) { } catch (e) {
// non-fatal — panel just shows defaults renderDefaults();
_krRenderVipList(KR_DEFAULT_VIP_FOLDERS);
_krRenderFmtList(KR_DEFAULT_FMTS);
} }
} }
+713
View File
@@ -0,0 +1,713 @@
// ---- Library Issues ----
let lastLibraryIssues = null;
let _libraryIssuesDirty = false;
let _libraryIssueTypeFilter = "all";
let _missingResolutionExtFilter = "all";
function _libraryIssueExportItems(r) {
const missingRes = r?.missing_resolution || [];
const visibleMissingRes = _missingResolutionExtFilter === "all"
? missingRes
: missingRes.filter((e) => e.extension === _missingResolutionExtFilter);
const includeAll = _libraryIssueTypeFilter === "all";
return {
bracketNames: includeAll ? (r?.bracket_names || []) : [],
noHyphenNames: includeAll ? (r?.nohyphen_names || []) : [],
resolutionNoncanonical: includeAll || _libraryIssueTypeFilter === "noncanonical"
? (r?.resolution_noncanonical || [])
: [],
missingResolution: includeAll || _libraryIssueTypeFilter === "missing" ? visibleMissingRes : [],
};
}
function _safeExportToken(value) {
return String(value || "all").replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "") || "all";
}
function _downloadJson(filename, data) {
const blob = new Blob([JSON.stringify(data, null, 2) + "\n"], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function _libraryIssueKindLabel(entry) {
const labels = {
resolution_copy_suffix: "copy suffix",
resolution_part_suffix: "part suffix",
resolution_bare_suffix: "bare res",
resolution_placeholder_empty: "empty []",
quality_marker_not_resolution: "quality tag",
suspicious_bracket_token: "bad bracket",
multipart_without_resolution: "part marker",
missing_resolution: "missing res",
};
const kinds = (entry.issues || [])
.map((issue) => labels[issue.kind] || issue.kind)
.filter(Boolean);
return kinds.length ? kinds.join(" · ") : "Report only";
}
function _canRenameIdFixRow(row) {
return row
&& !row.classList.contains("report-only")
&& ["bracket_id", "nohyphen_id"].includes(row.dataset.issue)
&& row.dataset.remote
&& row.dataset.old
&& row.dataset.new;
}
function renderLibraryIssues(r) {
const out = document.getElementById("library-issues-modal-body");
const statusEl = document.getElementById("library-issues-results");
const renameAllBtn = document.getElementById("library-issues-rename-all");
const exportBtn = document.getElementById("library-issues-export");
const renameStatus = document.getElementById("library-issues-rename-status");
if (!r || !r.ok) {
lastLibraryIssues = null;
renameAllBtn.disabled = true;
exportBtn.disabled = true;
out.innerHTML = `<div class="li-empty" style="color:#f87171;">Error: ${escapeHtml(r?.error || "no response")}</div>`;
openModal("library-issues-modal");
return;
}
lastLibraryIssues = r;
const brackets = r.bracket_names || [];
const nohyphens = r.nohyphen_names || [];
const missingRes = r.missing_resolution || [];
const noncanonicalRes = r.resolution_noncanonical || [];
const renameableTotal = brackets.length + nohyphens.length;
const total = renameableTotal + missingRes.length + noncanonicalRes.length;
const showRenameable = _libraryIssueTypeFilter === "all";
const showNoncanonical = _libraryIssueTypeFilter === "all" || _libraryIssueTypeFilter === "noncanonical";
const showMissing = _libraryIssueTypeFilter === "all" || _libraryIssueTypeFilter === "missing";
try { chrome.storage.local.set({ badge_library_issues_count: total }); } catch {}
renameAllBtn.disabled = !showRenameable || renameableTotal === 0;
renameAllBtn.title = renameableTotal
? "Rename only bracket-wrapped and no-hyphen ID fixes"
: "No bracket-wrapped or no-hyphen ID fixes to rename";
exportBtn.disabled = total === 0;
renameStatus.textContent = "";
const parts = [];
if (!total) {
parts.push(`<div class="li-empty">✓ No library issues found. All filenames are canonical.</div>`);
} else {
const typeButtons = [
["all", "All", total],
["noncanonical", "Noncanonical", noncanonicalRes.length],
["missing", "Missing res", missingRes.length],
].map(([type, label, count]) => (
`<button type="button" class="li-filter-chip li-type-chip${_libraryIssueTypeFilter === type ? " active" : ""}" data-type-filter="${escapeHtml(type)}">
<span>${escapeHtml(label)}</span><span>${Number(count).toLocaleString()}</span>
</button>`
)).join("");
parts.push(`<div class="li-stats with-filters">
<span><b>${total}</b> cache issue${total !== 1 ? "s" : ""} — <b>${brackets.length}</b> bracket-wrapped, <b>${nohyphens.length}</b> no-hyphen, <b>${missingRes.length}</b> missing resolution tag, <b>${noncanonicalRes.length}</b> noncanonical resolution</span>
<span class="li-filter-group">${typeButtons}</span>
</div>`);
const makeRow = (entry, tagClass, tagLabel) => {
const fname = entry.path.split("/").pop();
const dir = entry.path.lastIndexOf("/") !== -1 ? entry.path.slice(0, entry.path.lastIndexOf("/") + 1) : "";
return `<div class="li-row" data-issue="${escapeHtml(entry.issue)}" data-remote="${escapeHtml(entry.remote)}" data-old="${escapeHtml(entry.path)}" data-new="${escapeHtml(dir + entry.canonical_name)}">
<span class="li-tag ${tagClass}">${tagLabel}</span>
<div class="li-names">
<span class="li-old" title="${escapeHtml(entry.path)}">${escapeHtml(fname)}</span>
<span class="li-new" title="${escapeHtml(entry.canonical_name)}">→ ${escapeHtml(entry.canonical_name)}</span>
</div>
<span class="li-sz">${escapeHtml(entry.size_human || "")}</span>
<button class="li-rename-btn" type="button">Rename</button>
</div>`;
};
const makeReportRow = (entry, tagLabel = "no res", tagClass = "missingres") => {
const fname = entry.filename || entry.path.split("/").pop();
return `<div class="li-row report-only" data-remote="${escapeHtml(entry.remote)}" data-old="${escapeHtml(entry.path)}">
<span class="li-tag ${tagClass}">${escapeHtml(tagLabel)}</span>
<div class="li-names">
<span class="li-old" title="${escapeHtml(entry.full_path || entry.path)}">${escapeHtml(fname)}</span>
<span class="li-new" title="${escapeHtml(_libraryIssueKindLabel(entry))}">${escapeHtml(entry.path)}</span>
</div>
<span class="li-sz">${escapeHtml(entry.size_human || "")}</span>
<span class="li-action-note">${escapeHtml(_libraryIssueKindLabel(entry))}</span>
</div>`;
};
if (showRenameable && brackets.length) {
parts.push(`<div class="li-section-head">Bracket-wrapped IDs (${brackets.length})</div>`);
parts.push(brackets.map((e) => makeRow(e, "bracket", "[ ]")).join(""));
}
if (showRenameable && nohyphens.length) {
parts.push(`<div class="li-section-head">No-hyphen IDs (${nohyphens.length})</div>`);
parts.push(nohyphens.map((e) => makeRow(e, "nohyphen", "no hyphen")).join(""));
}
if (showNoncanonical && noncanonicalRes.length) {
parts.push(`<div class="li-section-head">Resolution present, noncanonical (${noncanonicalRes.length})</div>`);
parts.push(noncanonicalRes.map((e) => makeReportRow(e, "res style", "noncanonres")).join(""));
}
if (showMissing && missingRes.length) {
const summary = r.missing_resolution_summary || {};
const byExt = summary.by_extension || {};
const extEntries = Object.entries(byExt).sort(([a], [b]) => a.localeCompare(b));
const extButtons = [
["all", "All", missingRes.length],
...extEntries.map(([ext, count]) => [ext, ext, count]),
].map(([ext, label, count]) => (
`<button type="button" class="li-filter-chip${_missingResolutionExtFilter === ext ? " active" : ""}" data-ext-filter="${escapeHtml(ext)}">
<span>${escapeHtml(label)}</span><span>${Number(count).toLocaleString()}</span>
</button>`
)).join("");
const visibleMissingRes = _libraryIssueExportItems(r).missingResolution;
parts.push(`<div class="li-section-head with-filters">
<span>Missing resolution tag (${missingRes.length})</span>
<span class="li-filter-group">${extButtons}</span>
</div>`);
parts.push(visibleMissingRes.map((e) => makeReportRow(e)).join(""));
}
}
out.innerHTML = parts.join("");
statusEl.textContent = total
? `${total} library issue(s) found. Review window is open.`
: "No library issues found.";
openModal("library-issues-modal");
// Per-row rename buttons
out.querySelectorAll(".li-rename-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
const row = btn.closest(".li-row");
if (!_canRenameIdFixRow(row)) return;
const remote = row.dataset.remote;
const oldPath = row.dataset.old;
const newPath = row.dataset.new;
btn.disabled = true;
btn.textContent = "…";
const res = await chrome.runtime.sendMessage({
type: "rename_file", remote, old_path: oldPath, new_path: newPath,
});
const tag = row.querySelector(".li-tag");
if (res?.ok) {
tag.className = "li-tag done";
tag.textContent = "✓";
btn.textContent = "Done";
row.querySelector(".li-old").style.textDecoration = "line-through";
_libraryIssuesDirty = true;
} else if (res?.conflict) {
tag.className = "li-tag conflict";
tag.textContent = "conflict";
btn.textContent = "Skip";
renameStatus.textContent = `Conflict: ${res.error || "target exists"}`;
} else {
tag.className = "li-tag conflict";
tag.textContent = "error";
btn.textContent = "Error";
renameStatus.textContent = res?.error || "rename failed";
}
});
});
out.querySelectorAll(".li-filter-chip").forEach((btn) => {
btn.addEventListener("click", () => {
if (btn.dataset.typeFilter) {
_libraryIssueTypeFilter = btn.dataset.typeFilter || "all";
if (_libraryIssueTypeFilter !== "missing") _missingResolutionExtFilter = "all";
} else {
_missingResolutionExtFilter = btn.dataset.extFilter || "all";
_libraryIssueTypeFilter = "missing";
}
renderLibraryIssues(lastLibraryIssues);
});
});
}
document.getElementById("library-issues-run").addEventListener("click", async () => {
const out = document.getElementById("library-issues-modal-body");
out.innerHTML = `<div class="li-stats">Loading library issues from cache…</div>`;
openModal("library-issues-modal");
renderLibraryIssues(await chrome.runtime.sendMessage({ type: "library_issues" }));
});
document.getElementById("library-issues-rename-all").addEventListener("click", async () => {
const rows = [...document.querySelectorAll("#library-issues-modal-body .li-row")];
const renameStatus = document.getElementById("library-issues-rename-status");
const renameAllBtn = document.getElementById("library-issues-rename-all");
// Collect only legacy ID-fix renames. Resolution hygiene rows are report-only
// until they have explicit, reviewed rename proposals.
const pending = rows.reduce((acc, row) => {
const btn = row.querySelector(".li-rename-btn");
if (!btn || btn.disabled || !_canRenameIdFixRow(row)) return acc;
acc.push({ row, remote: row.dataset.remote, old_path: row.dataset.old, new_path: row.dataset.new });
return acc;
}, []);
if (!pending.length) {
renameStatus.textContent = "No ID-fix rows are available to rename.";
return;
}
const previewLimit = 12;
const previewLines = pending.slice(0, previewLimit).map(({ old_path, new_path }) => (
`${old_path}\n -> ${new_path}`
));
const remaining = pending.length - previewLines.length;
const ok = confirm(
`Rename ${pending.length} ID-fix file(s)?\n\n`
+ previewLines.join("\n\n")
+ (remaining > 0 ? `\n\n...and ${remaining} more.` : "")
);
if (!ok) {
renameStatus.textContent = "Rename ID fixes cancelled.";
return;
}
renameAllBtn.disabled = true;
renameStatus.textContent = `Renaming ${pending.length} ID-fix file(s)…`;
const renames = pending.map(({ remote, old_path, new_path }) => ({ remote, old_path, new_path }));
const res = await chrome.runtime.sendMessage({ type: "rename_files_batch", renames });
const results = res?.results || [];
let done = 0, conflicts = 0, errors = 0;
results.forEach((r, i) => {
const { row } = pending[i];
const tag = row.querySelector(".li-tag");
const btn = row.querySelector(".li-rename-btn");
if (r.ok) {
tag.className = "li-tag done"; tag.textContent = "✓";
btn.disabled = true; btn.textContent = "Done";
row.querySelector(".li-old").style.textDecoration = "line-through";
done++;
} else if (r.conflict) {
tag.className = "li-tag conflict"; tag.textContent = "conflict";
btn.disabled = false; btn.textContent = "Skip";
conflicts++;
} else {
tag.className = "li-tag conflict"; tag.textContent = "error";
btn.disabled = false; btn.textContent = "Error";
errors++;
}
});
const parts = [];
if (done) parts.push(`${done} renamed`);
if (conflicts) parts.push(`${conflicts} conflict(s)`);
if (errors) parts.push(`${errors} error(s)`);
renameStatus.textContent = parts.join(" · ") || "Nothing to rename.";
renameAllBtn.disabled = false;
_libraryIssuesDirty = done > 0;
});
document.getElementById("library-issues-export").addEventListener("click", () => {
if (!lastLibraryIssues?.ok) return;
const { bracketNames, noHyphenNames, resolutionNoncanonical, missingResolution } = _libraryIssueExportItems(lastLibraryIssues);
const activeFilter = _missingResolutionExtFilter || "all";
const activeType = _libraryIssueTypeFilter || "all";
const payload = {
export_type: "rclone_jav_library_issues",
generated_at: new Date().toISOString(),
source: "cache",
active_issue_type_filter: activeType,
active_missing_resolution_filter: activeFilter,
counts: {
bracket_wrapped: bracketNames.length,
no_hyphen: noHyphenNames.length,
resolution_noncanonical: resolutionNoncanonical.length,
missing_resolution: missingResolution.length,
total: bracketNames.length + noHyphenNames.length + resolutionNoncanonical.length + missingResolution.length,
full_cache_missing_resolution: lastLibraryIssues.missing_resolution?.length || 0,
full_cache_resolution_noncanonical: lastLibraryIssues.resolution_noncanonical?.length || 0,
},
bracket_names: bracketNames,
nohyphen_names: noHyphenNames,
resolution_noncanonical: resolutionNoncanonical,
missing_resolution: missingResolution,
};
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const filterToken = _safeExportToken(`${activeType}-${activeType === "missing" ? activeFilter : "all"}`);
_downloadJson(`rclone-jav-library-issues-${filterToken}-${stamp}.json`, payload);
const renameStatus = document.getElementById("library-issues-rename-status");
renameStatus.textContent = `Exported ${payload.counts.total.toLocaleString()} row(s) as JSON.`;
});
function _closeLibraryIssues() {
closeModal("library-issues-modal");
if (_libraryIssuesDirty) {
_libraryIssuesDirty = false;
chrome.runtime.sendMessage({ type: "library_issues" }, (r) => {
if (!r || !r.ok) return;
const total = (r.bracket_names?.length || 0)
+ (r.nohyphen_names?.length || 0)
+ (r.missing_resolution?.length || 0)
+ (r.resolution_noncanonical?.length || 0);
document.getElementById("library-issues-results").textContent = total
? `${total} library issue(s) found. Review window is open.`
: "No library issues found.";
});
}
}
for (const id of ["library-issues-modal-close", "library-issues-modal-done"]) {
document.getElementById(id).addEventListener("click", _closeLibraryIssues);
}
document.getElementById("library-issues-modal").addEventListener("click", (e) => {
if (e.target.id === "library-issues-modal") _closeLibraryIssues();
});
(function () {
const rebuildBtn = document.getElementById("cache-rebuild-run");
const rebuildMode = document.getElementById("cache-rebuild-mode");
const cacheStatusOut = document.getElementById("cache-status-results");
const scanJobOut = document.getElementById("scan-job-results");
let _optScanTimer = null;
let _optScanning = false;
const _stopOptPoll = () => { if (_optScanTimer) { clearInterval(_optScanTimer); _optScanTimer = null; } };
function _setOptScanningState(scanning) {
_optScanning = scanning;
rebuildBtn.textContent = scanning ? "✕ Cancel" : "Rebuild Cache";
if (rebuildMode) rebuildMode.disabled = scanning;
rebuildBtn.style.background = scanning ? "#3a1a1a" : "";
rebuildBtn.style.borderColor = scanning ? "#722" : "";
rebuildBtn.style.color = scanning ? "#faa" : "";
}
function _scanStatus(r) {
if (!r || r.no_state) return "idle";
if (r.scanning && !r.done) return "running";
if (r.cancelled) return "cancelled";
if (r.scan_ok === false) return "failed";
if (r.done) return "completed";
return "idle";
}
function _formatScanDuration(seconds) {
const s = Math.max(0, Math.round(Number(seconds) || 0));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
if (m < 60) return `${m}m ${rem}s`;
const h = Math.floor(m / 60);
const mm = m % 60;
return `${h}h ${mm}m`;
}
function _renderScanJob(r) {
if (!r || r.no_state) {
scanJobOut.innerHTML = `<span style="color:#777;">no scan job recorded yet</span>`;
return;
}
const status = _scanStatus(r);
const pillCls = status === "completed" ? "ok" : status === "failed" ? "fail" : "";
const jobLabel = status === "running" ? "Current Scan Job" : "Last Scan Job";
const mode = r.scan_since ? `incremental ${r.scan_since}` : "full";
const scope = (r.scope && r.scope.length) ? r.scope.join(", ") : "configured scan roots";
const finished = r.finished_at || r.started_at || "";
const when = finished ? new Date(finished).toLocaleString() : "";
let elapsed = r.elapsed_s != null ? _formatScanDuration(r.elapsed_s) : "";
if (!elapsed && r.started_at) {
const startedMs = Date.parse(r.started_at);
if (Number.isFinite(startedMs)) elapsed = _formatScanDuration((Date.now() - startedMs) / 1000);
}
const scanPct = Number.isFinite(r.scan_percent) ? `${Number(r.scan_percent).toFixed(1)}%${r.scan_total_known_complete === false ? " known" : ""}` : "";
const eta = r.scanning
? (Number.isFinite(r.scan_eta_s) ? _formatScanDuration(r.scan_eta_s) : "calculating")
: (status === "completed" ? "done" : "");
const knownCount = Number.isFinite(r.scan_files_done) && Number.isFinite(r.scan_files_total_known)
? `${Number(r.scan_files_done).toLocaleString()} / ${Number(r.scan_files_total_known).toLocaleString()}`
: "";
const meta = [mode, scope].filter(Boolean).join(" · ");
const metrics = [
["Progress", scanPct || "0.0%"],
["ETA", eta || "--"],
["Files", knownCount || "--"],
["Elapsed", elapsed || "0s"],
];
const jobs = (r.remote_jobs && r.remote_jobs.length)
? r.remote_jobs
: (r.remotes || []).map((remote, i) => ({
remote,
status: remote === r.current_remote ? status : i < (r.current_index || 0) ? "completed" : "queued",
files: remote === r.current_remote ? r.files_this_remote : null,
total: remote === r.current_remote ? r.files_remote_total : null,
}));
const jobRoots = jobs.map((j) => j.remote).filter(Boolean);
const retiredRoots = _configuredScanRoots.length
? jobRoots.filter((root) => !_configuredScanRoots.includes(root))
: [];
const jobRows = jobs.map((j) => {
const files = Number.isFinite(j.files) ? Number(j.files).toLocaleString() : "?";
const total = Number.isFinite(j.total) ? Number(j.total).toLocaleString() : "";
const pct = Number.isFinite(j.files) && Number.isFinite(j.total) && j.total > 0
? Math.min(100, Math.round((j.files / j.total) * 100)) : null;
const detail = [
j.label,
j.incremental ? "incremental" : "",
`${files}${total ? ` / ${total}` : ""} files`,
Number.isFinite(j.skipped) && j.skipped ? `${j.skipped} skipped` : "",
].filter(Boolean).join(" · ");
return `<div class="scan-remote">
<div class="scan-remote-head">
<span class="scan-remote-name">${escapeHtml(j.remote || "?")}</span>
<span class="scan-remote-status">${escapeHtml(j.status || "queued")}</span>
${pct != null ? `<span class="scan-remote-pct">${pct}%</span>` : ""}
</div>
<div class="scan-remote-detail">${escapeHtml(detail)}</div>
${pct != null ? `<div class="scan-track"><div class="scan-fill" style="width:${pct}%"></div></div>` : ""}
</div>`;
}).join("");
scanJobOut.innerHTML = `
<div class="scan-job-title">
<span>${escapeHtml(jobLabel)}</span>
${when ? `<span>${escapeHtml(when)}</span>` : ""}
</div>
<div class="scan-job-head">
<span class="scan-pill ${pillCls}">${escapeHtml(status)}</span>
<span class="scan-job-meta">${escapeHtml(meta || "scan job")}</span>
</div>
${metrics.length ? `<div class="scan-metrics">${metrics.map(([label, value]) => `
<span class="scan-metric"><span>${escapeHtml(label)}</span><b>${escapeHtml(value)}</b></span>
`).join("")}</div>` : ""}
${retiredRoots.length ? `<div class="section-note warn" style="margin:0 0 8px;">Historical scan roots not in current config: ${escapeHtml(retiredRoots.join(", "))}. They are shown because this job was recorded before the scan roots changed.</div>` : ""}
${r.error ? `<div style="color:#faa;margin-bottom:6px;">${escapeHtml(r.error)}</div>` : ""}
${jobRows || `<div style="color:#777;">waiting for remote progress...</div>`}
`;
}
const _pollOptProgress = () => {
chrome.runtime.sendMessage({ type: "scan-progress" }, (r) => {
if (chrome.runtime.lastError || !r || !r.ok) return;
_renderScanJob(r);
if (r.done || !r.scanning) {
_stopOptPoll();
_setOptScanningState(false);
if (r.cancelled) {
return;
} else if (r.scan_ok !== false) {
setTimeout(() => document.getElementById("cache-status-run").click(), 500);
}
return;
}
});
};
async function _refreshScanJob() {
try {
const cache = await chrome.runtime.sendMessage({ type: "cache-status" });
if (cache && cache.ok) rememberConfiguredScanRoots(cache);
} catch {}
_pollOptProgress();
}
async function _startOptScan(scanRoots = [], forceSince = null) {
const out = scanJobOut;
if (_optScanning) {
// Cancel in-progress scan
rebuildBtn.disabled = true;
rebuildBtn.textContent = "Cancelling…";
chrome.runtime.sendMessage({ type: "scan-cancel" }, () => {
rebuildBtn.disabled = false;
// State will update on next poll tick
});
return;
}
// forceSince overrides dropdown (used by per-remote Refresh to stay incremental)
const scanSince = forceSince !== null ? forceSince : (rebuildMode ? rebuildMode.value : "");
const scope = scanRoots.length ? `refresh ${scanRoots.join(", ")}` : "all configured scan roots";
const label = scanSince ? `incrementally update files changed in the last ${scanSince}` : "fully rebuild";
const button = scanRoots.length ? "Refresh" : "Rebuild";
if (!confirm(`${button} cache now?\n\nScope: ${scope}\nMode: ${label}\n\nThis can take several minutes.`)) return;
cacheStatusOut.innerHTML = `<span style="color:#6ec1ff;">starting scan…</span>`;
out.innerHTML = "";
try {
const r = await chrome.runtime.sendMessage({ type: "run-scan", scanSince, scanRoots });
if (!r || !r.ok) {
out.innerHTML = `<span style="color:#faa;">scan failed:</span> ${escapeHtml(r?.error || "no response")}`;
return;
}
_setOptScanningState(true);
_pollOptProgress();
_optScanTimer = setInterval(_pollOptProgress, 1500);
} catch (err) {
out.innerHTML = `<span style="color:#faa;">scan failed:</span> ${escapeHtml(err.message || String(err))}`;
}
}
rebuildBtn.addEventListener("click", () => _startOptScan());
function _renderNonJavPanel(items, remote) {
const panel = document.createElement("div");
panel.className = "nonjav-panel";
panel.dataset.remote = remote;
const deleteEnabled = document.getElementById("enableDelete")?.checked;
const delBtnHtml = deleteEnabled
? `<button class="nonjav-del-all" type="button" title="Delete all non-JAV files in this remote">Delete All (${items.length})</button>`
: `<span style="font-size:11px;color:#555;">Enable deletion in settings to delete</span>`;
panel.innerHTML = `
<div class="nonjav-panel-head">
<span class="nonjav-panel-title">${escapeHtml(remote)} · ${items.length} non-JAV file${items.length !== 1 ? "s" : ""}</span>
${delBtnHtml}
</div>
<div class="nonjav-list">${items.map(f => `
<div class="nonjav-item" data-full-path="${escapeHtml(f.full_path)}">
<span class="nonjav-ext">${escapeHtml(f.ext || "?")}</span>
<span class="nonjav-path" title="${escapeHtml(f.full_path)}">${escapeHtml(f.path)}</span>
${deleteEnabled ? `<button class="nonjav-del-one" type="button">Delete</button>` : ""}
</div>`).join("")}
</div>
<div class="nonjav-status"></div>`;
// Delete one
panel.addEventListener("click", async (e) => {
const btn = e.target.closest(".nonjav-del-one");
if (btn) {
const item = btn.closest(".nonjav-item");
const path = item?.dataset.fullPath;
if (!path) return;
if (!confirm(`Delete?\n${path}`)) return;
btn.disabled = true;
const r = await chrome.runtime.sendMessage({ type: "delete-skipped", paths: [path] });
if (r?.ok) {
item.classList.add("deleted");
item.querySelector(".nonjav-del-one")?.remove();
_updateNonJavDelAll(panel);
} else {
btn.disabled = false;
panel.querySelector(".nonjav-status").textContent = "Error: " + (r?.error || "failed");
}
return;
}
const delAll = e.target.closest(".nonjav-del-all");
if (delAll) {
const allItems = [...panel.querySelectorAll(".nonjav-item:not(.deleted)")];
const paths = allItems.map(i => i.dataset.fullPath).filter(Boolean);
if (!paths.length) return;
if (!confirm(`Delete all ${paths.length} non-JAV file(s) from ${remote}?`)) return;
delAll.disabled = true;
const statusEl = panel.querySelector(".nonjav-status");
statusEl.textContent = `Deleting ${paths.length} file(s)…`;
const r = await chrome.runtime.sendMessage({ type: "delete-skipped", paths });
const ok = r?.deleted_count || 0;
const fail = r?.failed_count || 0;
if (ok) {
// Mark successfully deleted items
const deletedPaths = new Set(
(r.results || []).filter(x => x.ok).map(x => x.path)
);
allItems.forEach(i => {
if (deletedPaths.has(i.dataset.fullPath)) {
i.classList.add("deleted");
i.querySelector(".nonjav-del-one")?.remove();
}
});
_updateNonJavDelAll(panel);
}
statusEl.textContent = fail
? `Deleted ${ok}, failed ${fail}. Check deletion settings.`
: `Deleted ${ok} file(s).`;
}
});
return panel;
}
function _updateNonJavDelAll(panel) {
const remaining = panel.querySelectorAll(".nonjav-item:not(.deleted)").length;
const btn = panel.querySelector(".nonjav-del-all");
if (btn) {
btn.textContent = `Delete All (${remaining})`;
btn.disabled = remaining === 0;
}
}
cacheStatusOut.addEventListener("click", (event) => {
const showSkipped = event.target.closest(".cache-show-skipped");
if (showSkipped) {
const remote = showSkipped.dataset.remote;
// Toggle: if panel already open, close it
const existing = cacheStatusOut.querySelector(`.nonjav-panel[data-remote="${CSS.escape(remote)}"]`);
if (existing) { existing.remove(); showSkipped.textContent = showSkipped.textContent.replace("▴", "▾"); return; }
showSkipped.textContent = showSkipped.textContent.replace("▾", "▴");
// Find skipped items from last cache status result
const items = (_cacheSkippedByRemote?.get(remote)) || [];
const panel = _renderNonJavPanel(items, remote);
// Insert after the row containing this button
showSkipped.closest("div")?.after(panel);
return;
}
const reextract = event.target.closest(".cache-reextract");
if (reextract) {
const original = reextract.textContent;
reextract.disabled = true;
reextract.textContent = "Re-extracting…";
(async () => {
try {
const r = await chrome.runtime.sendMessage({ type: "reextract-ids" });
if (!r || !r.ok) {
reextract.textContent = original;
reextract.disabled = false;
const note = document.createElement("div");
note.style.cssText = "color:#faa;margin-top:6px;font-size:11px;";
note.textContent = `Re-extract failed: ${r?.error || "no response"}`;
reextract.after(note);
return;
}
const note = document.createElement("div");
note.style.cssText = "color:#afa;margin-top:6px;font-size:11px;";
note.textContent = `Re-extracted ${r.total ?? 0} IDs · ${r.changed ?? 0} changed · ${r.unchanged ?? 0} unchanged · ${r.dropped ?? 0} dropped. Re-run Check Cache to refresh this view.`;
reextract.replaceWith(note);
} catch (err) {
reextract.textContent = original;
reextract.disabled = false;
const note = document.createElement("div");
note.style.cssText = "color:#faa;margin-top:6px;font-size:11px;";
note.textContent = `Re-extract failed: ${err?.message || String(err)}`;
reextract.after(note);
}
})();
return;
}
const refresh = event.target.closest(".cache-refresh-remote");
if (refresh) {
const remote = refresh.dataset.remote || "";
if (!remote) return;
// Per-remote Refresh is always incremental — inherit dropdown value if it's a
// duration (not "Full Rebuild"), otherwise default to 24h.
const dropdownVal = rebuildMode ? rebuildMode.value : "";
const refreshSince = dropdownVal || "24h";
_startOptScan([remote], refreshSince);
return;
}
});
document.getElementById("scan-job-clear").addEventListener("click", async () => {
if (!confirm("Clear recorded scan job history?\n\nThis only clears the Scan Job panel state. It does not change cache.json.")) return;
scanJobOut.textContent = "clearing scan job history...";
const r = await chrome.runtime.sendMessage({ type: "scan-clear" });
if (!r || !r.ok) {
scanJobOut.innerHTML = `<span style="color:#faa;">clear failed:</span> ${escapeHtml(r?.error || "no response")}`;
return;
}
_renderScanJob({ ok: true, no_state: true });
});
// If Options is opened while a scan is already running, attach to it instead
// of showing an idle Rebuild button.
_refreshScanJob();
chrome.runtime.sendMessage({ type: "scan-progress" }, (r) => {
if (chrome.runtime.lastError || !r || !r.ok) return;
_renderScanJob(r);
if (!r.scanning) return;
_setOptScanningState(true);
_optScanTimer = setInterval(_pollOptProgress, 1500);
});
})();
+265
View File
@@ -0,0 +1,265 @@
// ---------- profiles ----------
let _knownRemotes = []; // ["cq:", "gdrive:", ...] from rclone listremotes
let _cfgDefaults = { source: [], target: [] };
let _remotesLoaded = false;
async function fetchRemotes() {
const status = document.getElementById("profiles-status");
if (_remotesLoaded) return;
_remotesLoaded = true;
if (status) status.textContent = "loading remotes...";
try {
const r = await chrome.runtime.sendMessage({ type: "list-remotes" });
if (r && r.ok) {
_knownRemotes = r.remotes || [];
_cfgDefaults = { source: r.default_source || [], target: r.default_target || [] };
if (status) status.textContent = `${_knownRemotes.length} remote(s) loaded`;
// Re-render to populate selects now that we have data
const profiles = readProfiles();
renderProfiles(profiles);
updateSectionSummaries();
}
} catch (e) {
_remotesLoaded = false;
if (status) status.textContent = "failed to load remotes";
}
}
document.querySelector('.side .item[data-pane="profiles"]').addEventListener("click", fetchRemotes);
document.getElementById("load-remotes").addEventListener("click", () => {
_remotesLoaded = false;
fetchRemotes();
});
/**
* Build a remote picker widget.
* Shows: a <select> dropdown of known remotes + an editable path suffix input +
* an Add button. Added remotes appear as chips below with × to remove.
* Falls back gracefully to a plain text input if no remotes loaded yet.
*/
function buildRemotePicker(container, values) {
container.innerHTML = "";
// --- selected list ---
const selectedList = document.createElement("div");
selectedList.className = "prof-selected-list";
selectedList.style.cssText = "margin-bottom:6px;";
container.appendChild(selectedList);
function addChip(path) {
const chip = document.createElement("div");
chip.className = "prof-chip";
chip.style.cssText = "display:flex;align-items:center;gap:6px;margin-bottom:4px;";
// Editable path so user can adjust subpath after picking
const inp = document.createElement("input");
inp.type = "text";
inp.value = path;
inp.className = "prof-chip-input";
inp.style.cssText = "flex:1;font-family:Consolas,monospace;font-size:12px;";
const rm = document.createElement("button");
rm.type = "button";
rm.textContent = "×";
rm.title = "Remove";
rm.style.cssText = "background:#511;border:1px solid #722;color:#faa;border-radius:3px;padding:0 7px;cursor:pointer;font-size:14px;line-height:1;";
rm.addEventListener("click", () => chip.remove());
chip.appendChild(inp);
chip.appendChild(rm);
selectedList.appendChild(chip);
}
// Pre-populate with existing values
for (const v of (values || [])) addChip(v);
// --- add row: select + optional subpath + Add button ---
const addRow = document.createElement("div");
addRow.style.cssText = "display:flex;gap:6px;align-items:center;flex-wrap:wrap;";
const sel = document.createElement("select");
sel.style.cssText = "background:#0d0d0d;color:#ddd;border:1px solid #2a2a2a;border-radius:4px;padding:5px 8px;font-family:Consolas,monospace;font-size:12px;min-width:130px;";
if (_knownRemotes.length) {
for (const r of _knownRemotes) {
const opt = document.createElement("option");
opt.value = r;
opt.textContent = r;
sel.appendChild(opt);
}
} else {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "(loading…)";
sel.appendChild(opt);
}
const subpathInp = document.createElement("input");
subpathInp.type = "text";
subpathInp.placeholder = "optional/subpath";
subpathInp.style.cssText = "flex:1;min-width:120px;font-family:Consolas,monospace;font-size:12px;";
subpathInp.title = "Append a subpath to narrow the remote, e.g. JAV/ClearJAV";
const addBtn = document.createElement("button");
addBtn.type = "button";
addBtn.textContent = "+ Add";
addBtn.style.cssText = "padding:5px 12px;font-size:12px;white-space:nowrap;";
addBtn.addEventListener("click", () => {
const base = sel.value.trim();
if (!base) return;
const sub = subpathInp.value.trim().replace(/^\//, "");
const full = sub ? base + sub : base;
addChip(full);
subpathInp.value = "";
});
// Also allow typing a fully custom path
const customInp = document.createElement("input");
customInp.type = "text";
customInp.placeholder = "or type full path (e.g. cq:JAV/ClearJAV)";
customInp.style.cssText = "flex:1;min-width:160px;font-family:Consolas,monospace;font-size:12px;margin-top:4px;";
const customBtn = document.createElement("button");
customBtn.type = "button";
customBtn.textContent = "+ Add";
customBtn.style.cssText = "padding:5px 12px;font-size:12px;margin-top:4px;white-space:nowrap;";
customBtn.addEventListener("click", () => {
const v = customInp.value.trim();
if (v) { addChip(v); customInp.value = ""; }
});
customInp.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); customBtn.click(); } });
addRow.appendChild(sel);
addRow.appendChild(subpathInp);
addRow.appendChild(addBtn);
container.appendChild(addRow);
const customRow = document.createElement("div");
customRow.style.cssText = "display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:4px;";
customRow.appendChild(customInp);
customRow.appendChild(customBtn);
container.appendChild(customRow);
}
function readRemoteGroup(container) {
return [...container.querySelectorAll(".prof-chip-input")]
.map(i => i.value.trim()).filter(Boolean);
}
function describeProfileRoots(label, values, defaults) {
const roots = (values || []).filter(Boolean);
if (roots.length) return `${label}: ${roots.join(", ")}`;
if ((defaults || []).length) return `${label}: config default ${defaults.join(", ")}`;
return `${label}: config.json default`;
}
function cloneProfile(profile) {
return {
name: (profile?.name || "").trim(),
source: [...(profile?.source || [])].filter(Boolean),
target: [...(profile?.target || [])].filter(Boolean),
};
}
function buildProfileRow(profile) {
const p = cloneProfile(profile);
const row = document.createElement("div");
row.className = "profile-card prof-row";
row._profile = p;
const detail = document.createElement("div");
detail.innerHTML = `
<div class="name">${escapeHtml(p.name)}</div>
<div class="roots">${escapeHtml(describeProfileRoots("Source", p.source, _cfgDefaults.source))}<br>${escapeHtml(describeProfileRoots("Target", p.target, _cfgDefaults.target))}</div>
`;
const actions = document.createElement("div");
actions.className = "actions";
const editBtn = document.createElement("button");
editBtn.type = "button";
editBtn.textContent = "Edit";
editBtn.addEventListener("click", () => openProfileModal(row));
const delBtn = document.createElement("button");
delBtn.type = "button";
delBtn.className = "danger";
delBtn.textContent = "Remove";
delBtn.addEventListener("click", () => {
row.remove();
updateSectionSummaries();
});
actions.appendChild(editBtn);
actions.appendChild(delBtn);
row.appendChild(detail);
row.appendChild(actions);
return row;
}
function renderProfiles(profiles) {
const list = document.getElementById("profiles-list");
list.innerHTML = "";
if (!profiles.length) {
const msg = document.createElement("div");
msg.style.cssText = "color:#666;font-size:12px;font-style:italic;margin-bottom:8px;";
msg.textContent = "No profiles defined. Searches use rc-jav's config.json defaults.";
list.appendChild(msg);
return;
}
for (const p of profiles) list.appendChild(buildProfileRow(p));
}
function readProfiles() {
return [...document.querySelectorAll("#profiles-list .prof-row")]
.map((row) => cloneProfile(row._profile))
.filter((profile) => profile.name);
}
let editingProfileRow = null;
function setProfileModalDefaultsNote() {
const src = _cfgDefaults.source.length ? _cfgDefaults.source.join(", ") : "config.json default_source";
const tgt = _cfgDefaults.target.length ? _cfgDefaults.target.join(", ") : "config.json default_target";
document.getElementById("profile-modal-status").textContent = `Empty remote lists inherit source ${src} and target ${tgt}.`;
}
async function openProfileModal(row = null) {
editingProfileRow = row;
await fetchRemotes();
const profile = cloneProfile(row?._profile);
document.getElementById("profile-modal-title").textContent = row ? "Edit Library Profile" : "Add Library Profile";
document.getElementById("profile-modal-name").value = profile.name;
buildRemotePicker(document.getElementById("profile-modal-source"), profile.source);
buildRemotePicker(document.getElementById("profile-modal-target"), profile.target);
setProfileModalDefaultsNote();
openModal("profile-modal");
document.getElementById("profile-modal-name").focus();
}
function closeProfileModal() {
editingProfileRow = null;
closeModal("profile-modal");
}
document.getElementById("add-profile").addEventListener("click", () => openProfileModal());
document.getElementById("profile-modal-save").addEventListener("click", () => {
const name = document.getElementById("profile-modal-name").value.trim();
const status = document.getElementById("profile-modal-status");
if (!name) {
status.textContent = "Profile name is required.";
return;
}
const profile = {
name,
source: readRemoteGroup(document.getElementById("profile-modal-source")),
target: readRemoteGroup(document.getElementById("profile-modal-target")),
};
const list = document.getElementById("profiles-list");
if (editingProfileRow) {
editingProfileRow.replaceWith(buildProfileRow(profile));
} else {
list.querySelector("div[style*='italic']")?.remove();
list.appendChild(buildProfileRow(profile));
}
closeProfileModal();
updateSectionSummaries();
});
for (const id of ["profile-modal-close", "profile-modal-cancel"]) {
document.getElementById(id).addEventListener("click", closeProfileModal);
}
document.getElementById("profile-modal").addEventListener("click", (event) => {
if (event.target.id === "profile-modal") closeProfileModal();
});
+328
View File
@@ -0,0 +1,328 @@
// ---------- adapters ----------
function renderAdapters(list) {
const tbody = document.querySelector("#adapters tbody");
tbody.innerHTML = "";
for (const a of list) addAdapterRow(a.host || "", a.selector || "");
if (list.length === 0) addAdapterRow("", "");
}
function addAdapterRow(host, selector) {
const tbody = document.querySelector("#adapters tbody");
const tr = document.createElement("tr");
tr.innerHTML = `
<td><input type="text" class="host" placeholder="clearjav.com"></td>
<td><input type="text" class="selector" placeholder=".some-class"></td>
<td><button class="del" type="button">×</button></td>`;
tr.querySelector(".host").value = host;
tr.querySelector(".selector").value = selector;
tr.querySelector(".del").addEventListener("click", () => tr.remove());
tbody.appendChild(tr);
}
function readAdapters() {
const rows = document.querySelectorAll("#adapters tbody tr");
const out = [];
for (const tr of rows) {
const host = tr.querySelector(".host").value.trim();
const selector = tr.querySelector(".selector").value.trim();
if (host && selector) out.push({ host, selector });
}
return out;
}
document.getElementById("add-adapter").addEventListener("click", () => addAdapterRow("", ""));
document.getElementById("validate-adapters").addEventListener("click", () => {
const status = document.getElementById("picker-status");
const rows = [...document.querySelectorAll("#adapters tbody tr")];
const seen = new Set();
const issues = [];
for (const tr of rows) {
const host = tr.querySelector(".host").value.trim();
const selector = tr.querySelector(".selector").value.trim();
tr.style.outline = "";
if (!host && !selector) continue;
if (!host || !selector) {
issues.push("rows need both host and selector");
tr.style.outline = "1px solid #775";
continue;
}
const key = host.toLowerCase();
if (seen.has(key)) {
issues.push(`duplicate host: ${host}`);
tr.style.outline = "1px solid #775";
}
seen.add(key);
try { document.querySelector(selector); } catch {
issues.push(`invalid CSS selector for ${host}`);
tr.style.outline = "1px solid #775";
}
}
status.textContent = issues.length ? [...new Set(issues)].join("; ") : `${readAdapters().length} adapter row(s) look valid`;
updateSectionSummaries();
});
// ---------- ID normalizers ----------
function renderNormalizers(list) {
const tbody = document.querySelector("#normalizers tbody");
tbody.innerHTML = "";
for (const n of list) addNormalizerRow(n.re || "", n.fmt || "");
if (list.length === 0) addNormalizerRow("", "");
}
function addNormalizerRow(re, fmt) {
const tbody = document.querySelector("#normalizers tbody");
const tr = document.createElement("tr");
tr.innerHTML = `
<td><input type="text" class="re" placeholder="\\b1pondo-?(\\d{4,})-?(\\d{2,})\\b"></td>
<td><input type="text" class="fmt" placeholder="1pondo-$1-$2"></td>
<td><button class="del" type="button">×</button></td>`;
tr.querySelector(".re").value = re;
tr.querySelector(".fmt").value = fmt;
tr.querySelector(".del").addEventListener("click", () => tr.remove());
tbody.appendChild(tr);
}
function readNormalizers() {
const rows = document.querySelectorAll("#normalizers tbody tr");
const out = [];
for (const tr of rows) {
const re = tr.querySelector(".re").value.trim();
const fmt = tr.querySelector(".fmt").value.trim();
if (re && fmt) out.push({ re, fmt });
}
return out;
}
document.getElementById("add-normalizer").addEventListener("click", () => addNormalizerRow("", ""));
document.getElementById("validate-normalizers").addEventListener("click", () => {
const status = document.getElementById("normalizer-status");
const rows = [...document.querySelectorAll("#normalizers tbody tr")];
const issues = [];
for (const tr of rows) {
tr.style.outline = "";
const re = tr.querySelector(".re").value.trim();
const fmt = tr.querySelector(".fmt").value.trim();
if (!re && !fmt) continue;
if (!re || !fmt) {
issues.push("rows need both regex and replacement");
tr.style.outline = "1px solid #775";
continue;
}
try { new RegExp(re, "i"); } catch (err) {
issues.push(`invalid regex: ${err.message}`);
tr.style.outline = "1px solid #775";
}
}
status.textContent = issues.length ? issues.join("; ") : `${readNormalizers().length} normalizer row(s) look valid`;
updateSectionSummaries();
});
// ---------- custom part detectors ----------
const PART_DETECTOR_SAMPLES = [
"KV-118 - Aiba Reika_PART1.mp4",
"KV-118 - Aiba Reika_PART2.mp4",
"KV-118 - Aiba Reika_PART3.mp4",
"KV-118_1.mp4",
"KV-118_2.mp4",
"KV-118-pt1.mp4",
"KV-118-part2.mp4",
"KV-118-cd1.mp4",
"KV-118-disc2.mp4",
"KV-118 (1).mp4",
"KV-118 (1 of 3).mp4",
"KV-118.1of3.mp4",
"KV-118-2 of 4.mp4",
"OFJE-195-1 [480p].mp4",
"OFJE-195-2 [480p].mp4",
"OFJE-195-3 [480p].mp4",
"KV-118_A.mp4",
"KV-118-B.mp4",
"KV-118A.mp4",
"KV-118 1.mp4",
"KV-118-P1.mp4",
"KV-118_P2.mp4",
"KV-118 Part 3.mp4",
"KV-118_EP1.mp4",
"KV-118 Episode 2.mp4",
"KV-118_Vol1.mp4",
"KV-118 Volume 2.mp4",
"KV-118_Scene1.mp4",
"KV-118_Side-A.mp4",
];
const BUILTIN_PART_DETECTORS = [
{ pattern: "[-_ ](?:pt|part|cd|disc)[-_ ]?(\\d+)$", note: "pt / part / cd / disc number" },
{ pattern: "\\s*\\((\\d+)(?:\\s*of\\s*\\d+)?\\)$", note: "parenthesized part number or X of Y" },
{ pattern: "[._ -](\\d+)\\s*of\\s*\\d+$", note: "X of Y suffix" },
{ pattern: "_(\\d{1,2})$", note: "underscore number" },
{ pattern: "-(\\d{1,2})$", note: "hyphen short part number" },
{ pattern: "[-_]([A-D])$", note: "lettered part with separator" },
{ pattern: "(?<=\\d)([A-D])$", note: "lettered part directly after ID" },
{ pattern: "\\s+(\\d{1,2})$", note: "trailing spaced number" },
];
function partDetectorStem(filename) {
return filename.replace(/\.[^.]+$/, "");
}
function partDetectorStemStages(filename) {
const raw = partDetectorStem(filename);
const resolutionClean = raw.replace(/\s*\[[^\]]*\]\s*$/, "").trim();
let actressClean = resolutionClean;
if (actressClean.includes(" - ")) actressClean = actressClean.slice(0, actressClean.indexOf(" - ")).trim();
const stages = [];
for (const [label, stem] of [
["raw stem", raw],
["after trailing metadata cleanup", resolutionClean],
["after actress cleanup", actressClean],
]) {
if (stem && !stages.some((stage) => stage.stem === stem)) stages.push({ label, stem });
}
return stages;
}
function partDetectorRegex(pattern) {
// Custom detectors are Python regexes, but the common detector subset is
// shared with browser RegExp. Preview the representative shapes here; rc-jav
// remains authoritative when the saved rule runs during scan/search.
return new RegExp(pattern, "i");
}
function builtinPartCoverage(filename) {
for (const detector of BUILTIN_PART_DETECTORS) {
try {
const re = partDetectorRegex(detector.pattern);
for (const stage of partDetectorStemStages(filename)) {
const match = stage.stem.match(re);
if (match && match[1]) return detector;
}
} catch {}
}
return null;
}
function updatePartDetectorFeedback(row) {
const feedback = row.querySelector(".part-detector-feedback");
const pattern = row.querySelector(".part-detector-pattern").value.trim();
if (!pattern) {
feedback.innerHTML = `<span class="warn">Enter a detector regex.</span> Capture group 1 should be the part token.`;
return;
}
let re;
try {
re = partDetectorRegex(pattern);
} catch (err) {
feedback.innerHTML = `<span class="fail">Invalid preview regex:</span> ${escapeHtml(err.message || String(err))}`;
return;
}
const matches = [];
let missingCapture = false;
for (const filename of PART_DETECTOR_SAMPLES) {
for (const stage of partDetectorStemStages(filename)) {
const match = stage.stem.match(re);
if (!match) continue;
if (!match[1]) missingCapture = true;
matches.push({ filename, part: match[1] || "?", stage: stage.label });
break;
}
}
if (!matches.length) {
feedback.innerHTML = `<span class="warn">No representative sample matched.</span> The rule may still be valid for a library-specific filename shape.`;
return;
}
const isBuiltin = row.classList.contains("builtin");
const covered = !isBuiltin ? matches.map((item) => ({ item, detector: builtinPartCoverage(item.filename) })) : [];
const alreadyCovered = covered.length && covered.every((entry) => entry.detector);
const coveredNote = alreadyCovered
? `<div class="warn">These representative matches are already covered by built-in detector${new Set(covered.map((entry) => entry.detector.pattern)).size === 1 ? "" : "s"}.</div>`
: "";
feedback.innerHTML = [
`<span class="${missingCapture ? "warn" : "ok"}">${missingCapture ? "Matched, but capture group 1 was missing for a sample." : `Matches ${matches.length} representative filename shape${matches.length === 1 ? "" : "s"}.`}</span>`,
coveredNote,
...matches.slice(0, 4).map((item) => `<div class="part-detector-match">${escapeHtml(item.filename)} -> part ${escapeHtml(item.part)} <span style="color:#777;">(${escapeHtml(item.stage)})</span></div>`),
matches.length > 4 ? `<div>and ${escapeHtml(matches.length - 4)} more representative match(es)</div>` : "",
].filter(Boolean).join("");
}
function addPartDetectorRow(pattern = "", { builtin = false, note = "" } = {}) {
const list = document.getElementById(builtin ? "builtin-part-detectors" : "part-detectors");
const row = document.createElement("div");
row.className = "part-detector-row" + (builtin ? " builtin" : "");
row.innerHTML = `
<div class="part-detector-head">
<input type="text" class="part-detector-pattern" placeholder="_PART(\\d+)$"${builtin ? " readonly" : ""}>
${builtin ? `<span class="part-detector-kind">Built in</span>` : `<button type="button" title="Remove detector">x</button>`}
</div>
${note ? `<div class="muted" style="margin-top:5px;">${escapeHtml(note)}</div>` : ""}
<div class="part-detector-feedback"></div>
`;
row.querySelector(".part-detector-pattern").value = pattern;
if (!builtin) {
row.querySelector(".part-detector-pattern").addEventListener("input", () => {
updatePartDetectorFeedback(row);
updateSectionSummaries();
});
row.querySelector("button").addEventListener("click", () => {
row.remove();
if (!list.children.length) addPartDetectorRow("");
updateSectionSummaries();
});
}
list.appendChild(row);
updatePartDetectorFeedback(row);
return row;
}
function renderPartDetectors(patterns) {
const builtinList = document.getElementById("builtin-part-detectors");
const list = document.getElementById("part-detectors");
builtinList.innerHTML = "";
list.innerHTML = "";
for (const detector of BUILTIN_PART_DETECTORS) addPartDetectorRow(detector.pattern, { builtin: true, note: detector.note });
for (const pattern of patterns || []) addPartDetectorRow(pattern);
if (!list.children.length) addPartDetectorRow("");
}
function readPartDetectors() {
return [...document.querySelectorAll("#part-detectors .part-detector-pattern")]
.map((input) => input.value.trim())
.filter(Boolean);
}
document.getElementById("add-part-detector").addEventListener("click", () => {
addPartDetectorRow("").querySelector(".part-detector-pattern").focus();
});
// Tester
document.getElementById("norm-test-run").addEventListener("click", async () => {
const input = document.getElementById("norm-test-in").value;
const out = document.getElementById("norm-test-out");
out.textContent = "testing text...";
try {
const r = await chrome.runtime.sendMessage({
type: "test-id-text",
text: input,
normalizers: readNormalizers(),
});
if (!r || !r.ok) {
out.innerHTML = `<span style="color:#faa;">error:</span> ${escapeHtml(r?.error || "no response")}`;
return;
}
const e = r.extracted || {};
out.innerHTML = [
`<div><span style="color:#777;">ID:</span> <span style="color:${e.id ? "#afa" : "#faa"};">${escapeHtml(e.id || "none")}</span></div>`,
`<div><span style="color:#777;">Rule:</span> ${escapeHtml(e.source || "none")}</div>`,
e.pattern ? `<div><span style="color:#777;">Pattern:</span> ${escapeHtml(e.pattern)}</div>` : "",
e.replacement ? `<div><span style="color:#777;">Replacement:</span> ${escapeHtml(e.replacement)}</div>` : "",
e.raw ? `<div><span style="color:#777;">Raw:</span> ${escapeHtml(e.raw)}</div>` : "",
].filter(Boolean).join("");
} catch (err) {
out.innerHTML = `<span style="color:#faa;">error:</span> ${escapeHtml(err.message || String(err))}`;
}
});
+9
View File
@@ -0,0 +1,9 @@
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[c]));
}
+78 -12
View File
@@ -3,6 +3,7 @@
* See mockups/console-consolidation-claude.html for sequence + rationale. * See mockups/console-consolidation-claude.html for sequence + rationale.
* Per-pane split happens later (step 6) alongside per-pane JS extraction. */ * 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; } body { font-family: -apple-system, Segoe UI, sans-serif; background: #0f0f0f; color: #ddd; margin: 0; padding: 24px; }
.shell { .shell {
max-width: 1040px; margin: 0 auto; 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 { color: #faa; }
.side .item.danger:hover { background: #2a1a1a; } .side .item.danger:hover { background: #2a1a1a; }
.side .item.danger.active { background: #3a1a1a; color: #ffbbbb; } .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 pane */
.main { padding: 26px 32px; overflow-y: auto; } .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-row { display:flex; gap:6px; align-items:center; flex-wrap:wrap; }
.chip-btn { padding: 4px 9px; font-size:11px; border-radius: 10px; } .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-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 { 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-entry { margin-top:8px; }
.activity-head { display:flex; align-items:center; gap:7px; flex-wrap:wrap; } .activity-head { display:flex; align-items:center; gap:7px; flex-wrap:wrap; }
.activity-pill { border:1px solid #333; border-radius:11px; padding:2px 8px; font-size:10px; font-weight:700; letter-spacing:.04em; text-transform:uppercase; white-space:nowrap; } .activity-pill { 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; } .muted { color:#777; font-size:11px; }
.disabled-soft { opacity:.48; } .disabled-soft { opacity:.48; }
.danger-zone { border-color:#5a2525; background:#201414; } .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 { 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.ok { background:#1a3a1a; color:#afa; border-color:#2e5a2e; }
.scan-pill.fail { background:#3a1a1a; color:#faa; border-color:#722; } .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-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; } .scan-fill { height:100%; background:#6ec1ff; min-width:0; }
/* buttons */ /* 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-issues-modal .modal-body::-webkit-scrollbar-thumb { background: #2a2a4a; border-radius: 3px; }
/* Library issue rows */ /* 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-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-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.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-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.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.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.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-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-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-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-new { font-family: Consolas, monospace; font-size: 13px; color: #c0c0e0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.li-sz { font-size: 11px; color: #6060aa; white-space: nowrap; } .li-sz { font-size: 13px; color: #7f82d8; 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-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:hover:not(:disabled) { background: rgba(255,255,255,.12); }
.li-rename-btn:disabled { opacity: 0.4; cursor: default; } .li-rename-btn:disabled { opacity: 0.4; cursor: default; }
.li-empty { padding: 24px 16px; color: #4ade80; font-size: 13px; } .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; } .li-stats b { color: #c8c8e8; }
/* Dupe Review filter bar */ /* Dupe Review filter bar */
+76 -47
View File
@@ -16,30 +16,26 @@
<span>Extension settings</span> <span>Extension settings</span>
</div> </div>
<div class="group"> <div class="group">
<div class="gtitle">Scanning</div> <div class="gtitle">Console</div>
<div class="item active" data-pane="triggers"><span class="icon"></span>Scan Behavior</div> <div class="item active" data-pane="dupe-review"><span class="label">Duplicate Review</span><span class="side-badge" data-badge="dupe-count"></span></div>
<div class="item" data-pane="overlays"><span class="icon"></span>Overlays</div> <div class="item" data-pane="search"><span class="label">Cache &amp; Scans</span><span class="side-badge" data-badge="cache-age"></span></div>
<div class="item" data-pane="library-issues"><span class="label">Library Issues</span><span class="side-badge" data-badge="library-issues-count"></span></div>
<div class="side-note">Bulk Check lives in its own window — popup launcher, not sidebar.</div>
</div> </div>
<div class="group"> <div class="group">
<div class="gtitle">Library</div> <div class="gtitle">Settings</div>
<div class="item" data-pane="profiles"><span class="icon"></span>Profiles</div> <div class="item" data-pane="profiles"><span class="label">Profiles</span></div>
<div class="item" data-pane="search"><span class="icon"></span>Cache &amp; Scans</div> <div class="item" data-pane="triggers"><span class="label">Scan Behavior</span></div>
<div class="item" data-pane="maintenance"><span class="icon"></span>Library Review</div> <div class="item" data-pane="normalizers"><span class="label">Matching Rules</span></div>
<div class="item" data-pane="adapters"><span class="label">Site Extraction</span></div>
<div class="item" data-pane="overlays"><span class="label">Overlays</span></div>
<div class="item danger" data-pane="deletion"><span class="label">Deletion</span></div>
</div> </div>
<div class="group"> <div class="group">
<div class="gtitle">Matching</div> <div class="gtitle">Support</div>
<div class="item" data-pane="adapters"><span class="icon"></span>Site Extraction</div> <div class="item" data-pane="paths"><span class="label">Setup</span></div>
<div class="item" data-pane="normalizers"><span class="icon"></span>ID Rules</div> <div class="item" data-pane="diagnostics"><span class="label">Diagnostics</span></div>
</div> <div class="item" data-pane="debug"><span class="label">Debug Tools</span></div>
<div class="group">
<div class="gtitle">System</div>
<div class="item" data-pane="paths"><span class="icon"></span>Setup</div>
<div class="item" data-pane="diagnostics"><span class="icon"></span>Diagnostics</div>
<div class="item" data-pane="debug"><span class="icon"></span>Debug Tools</div>
</div>
<div class="group">
<div class="gtitle">Danger</div>
<div class="item danger" data-pane="deletion"><span class="icon">×</span>Deletion</div>
</div> </div>
</div> </div>
@@ -47,7 +43,7 @@
<div class="main"> <div class="main">
<!-- TRIGGERS --> <!-- TRIGGERS -->
<div class="pane active" id="pane-triggers"> <div class="pane" id="pane-triggers">
<div class="pane-head"> <div class="pane-head">
<h1>Scan Behavior</h1> <h1>Scan Behavior</h1>
<div class="pdesc">Choose when rclone-jav checks the current page.</div> <div class="pdesc">Choose when rclone-jav checks the current page.</div>
@@ -286,10 +282,10 @@
</div> </div>
</div> </div>
<!-- ID RULES --> <!-- MATCHING RULES -->
<div class="pane" id="pane-normalizers"> <div class="pane" id="pane-normalizers">
<div class="pane-head"> <div class="pane-head">
<h1>ID Rules</h1> <h1>Matching Rules</h1>
<div class="pdesc">Normalize odd IDs and teach rc-jav how multipart filename suffixes should stay distinct.</div> <div class="pdesc">Normalize odd IDs and teach rc-jav how multipart filename suffixes should stay distinct.</div>
</div> </div>
<div id="normalizer-summary" class="section-note"></div> <div id="normalizer-summary" class="section-note"></div>
@@ -406,11 +402,11 @@
</div> </div>
</div> </div>
<!-- LIBRARY REVIEW --> <!-- DUPLICATE REVIEW -->
<div class="pane" id="pane-maintenance"> <div class="pane active" id="pane-dupe-review">
<div class="pane-head"> <div class="pane-head">
<h1>Library Review</h1> <h1>Duplicate Review</h1>
<div class="pdesc">Review duplicate groups, check IDs in bulk, and fix non-canonical filenames in your library.</div> <div class="pdesc">Review cached duplicate groups and tune the KEEP ranking that picks the surviving file.</div>
</div> </div>
<div class="fieldset"> <div class="fieldset">
@@ -469,28 +465,26 @@
</div> </div>
</div> </div>
</div>
<!-- LIBRARY ISSUES -->
<div class="pane" id="pane-library-issues">
<div class="pane-head">
<h1>Library Issues</h1>
<div class="pdesc">Find cache-only filename hygiene issues. Rename canonical-name fixes now; review missing resolution tags for later processing.</div>
</div>
<div class="fieldset"> <div class="fieldset">
<div class="ftitle">Library issues</div> <div class="ftitle">Library issues</div>
<div class="help">Find files with non-canonical names: bracket-wrapped IDs like <code>[REAL-779].mp4</code> and no-hyphen IDs like <code>MVSD312.avi</code>. Rename suggestions are computed from cache — no network required.</div> <div class="help">Find files with non-canonical names and missing final resolution tags like <code>BLK-474.mp4</code>. Rename suggestions are computed from cache — no network required.</div>
<div class="button-row"> <div class="button-row">
<button id="library-issues-run" type="button">Review Library Issues</button> <button id="library-issues-run" type="button">Review Library Issues</button>
</div> </div>
<div id="library-issues-results" class="mono-output"></div> <div id="library-issues-results" class="mono-output"></div>
</div> </div>
<div class="fieldset">
<div class="ftitle">Bulk ID check</div>
<div class="help">Paste IDs separated by lines, commas, or spaces. Uses the current LIVE/CACHE mode and active library profile.</div>
<textarea id="bulk-id-input" placeholder="BLK-474&#10;FC2-4865786&#10;PRTD-[027-030]"></textarea>
<div class="button-row" style="margin-top:8px;">
<button id="bulk-id-run" type="button">Check IDs</button>
<button id="bulk-id-clear" type="button">Clear</button>
</div>
<div id="bulk-id-results" class="mono-output"></div>
</div>
</div> </div>
<!-- SETUP --> <!-- SETUP (orphaned — not in sidebar; DOM kept so JS IDs resolve) -->
<div class="pane" id="pane-paths"> <div class="pane" id="pane-paths">
<div class="pane-head"> <div class="pane-head">
<h1>Setup</h1> <h1>Setup</h1>
@@ -511,6 +505,24 @@
</div> </div>
</div> </div>
<div class="fieldset">
<div class="ftitle">Alerts</div>
<div class="help">Send a Discord webhook on native-host errors (disconnects, timeouts, exceptions). Rate-limited to 1 alert per 10 minutes — same as the Windows notification. Leave URL blank to disable.</div>
<div class="compact-grid">
<input type="text" id="discordWebhookUrl" placeholder="https://discord.com/api/webhooks/...">
<button id="test-discord-webhook" type="button" title="Sends a test alert from the extension's background script (browser network path)">Test (extension)</button>
</div>
<div class="button-row" style="margin-top:6px;">
<button id="test-discord-host" type="button" title="Sends a test alert from the native host (Python urllib path). Verifies the host can post independently of the extension.">Test (host)</button>
<span class="muted" style="font-size:11px;">Both paths fire on real errors — test each to confirm Discord receives them.</span>
</div>
<div style="margin-top:6px;font-size:11px;color:#888;">PC label <span class="muted">(optional, embedded in alerts so you can tell which PC fired)</span></div>
<input type="text" id="pcLabel" placeholder="desktop · laptop · etc." style="width:240px;">
<div class="button-row" style="margin-top:8px;">
<span id="discord-status" class="muted"></span>
</div>
</div>
<div id="backup-summary" class="section-note"></div> <div id="backup-summary" class="section-note"></div>
<div class="fieldset"> <div class="fieldset">
@@ -583,6 +595,18 @@
<div id="diag-results" style="margin-top:14px;"></div> <div id="diag-results" style="margin-top:14px;"></div>
</div> </div>
<div class="fieldset">
<div class="ftitle">Native messaging log</div>
<div class="help">Last 200 RPC calls to the native host — action, latency, response size, and any error/disconnect reason. Use this when "Error when communicating with the native messaging host" shows up in Check Library or the popup.</div>
<div class="button-row">
<button id="native-log-run" type="button">Refresh</button>
<button id="native-log-clear" type="button" title="Clears the extension-side RPC log shown above (chrome.storage)">Clear Log</button>
<button id="host-events-clear" type="button" title="Truncates host/logs/rcjav-host-events.log on disk">Clear Host Events</button>
<label style="font-size:11px;color:#888;display:inline-flex;align-items:center;gap:6px;margin-left:auto;"><input type="checkbox" id="native-log-errors-only"> errors only</label>
</div>
<div id="native-log-results" class="mono-output"></div>
</div>
<div class="fieldset"> <div class="fieldset">
<div class="ftitle">Native host registration</div> <div class="ftitle">Native host registration</div>
<div class="help">Checks the manifest, extension ID permission, and Windows registry entries used by Brave/Chrome native messaging.</div> <div class="help">Checks the manifest, extension ID permission, and Windows registry entries used by Brave/Chrome native messaging.</div>
@@ -628,11 +652,10 @@
<button id="activity-clear" type="button">Clear Activity</button> <button id="activity-clear" type="button">Clear Activity</button>
</div> </div>
<div class="activity-filters" id="activity-filters" aria-label="Recent activity filters"> <div class="activity-filters" id="activity-filters" aria-label="Recent activity filters">
<button class="activity-filter active" type="button" data-activity-filter="all">All</button> <button class="activity-filter af-all active" type="button" data-activity-filter="all"><span class="af-lbl">All</span><span class="af-cnt" data-activity-count="all">0</span></button>
<button class="activity-filter" type="button" data-activity-filter="hit">Match</button> <button class="activity-filter af-hit" type="button" data-activity-filter="hit"><span class="af-lbl">Match</span><span class="af-cnt" data-activity-count="hit">0</span></button>
<button class="activity-filter" type="button" data-activity-filter="miss">No Match</button> <button class="activity-filter af-miss" type="button" data-activity-filter="miss"><span class="af-lbl">No Match</span><span class="af-cnt" data-activity-count="miss">0</span></button>
<button class="activity-filter" type="button" data-activity-filter="no_id">No ID</button> <button class="activity-filter af-other" type="button" data-activity-filter="other"><span class="af-lbl">Other</span><span class="af-cnt" data-activity-count="other">0</span></button>
<button class="activity-filter" type="button" data-activity-filter="other">Other</button>
</div> </div>
<div id="activity-results" class="mono-output"></div> <div id="activity-results" class="mono-output"></div>
</div> </div>
@@ -752,14 +775,15 @@
<div class="modal-head"> <div class="modal-head">
<div> <div>
<div class="modal-title" id="library-issues-modal-title">Library Issues</div> <div class="modal-title" id="library-issues-modal-title">Library Issues</div>
<div class="modal-subtitle">Non-canonical filenames detected in cache. Rename to fix.</div> <div class="modal-subtitle">Filename hygiene issues detected in cache.</div>
</div> </div>
<button id="library-issues-modal-close" type="button" title="Close">x</button> <button id="library-issues-modal-close" type="button" title="Close">x</button>
</div> </div>
<div id="library-issues-modal-body" class="modal-body"></div> <div id="library-issues-modal-body" class="modal-body"></div>
<div class="modal-actions"> <div class="modal-actions">
<span id="library-issues-rename-status" style="font-size:12px;color:#8888aa;flex:1;"></span> <span id="library-issues-rename-status" style="font-size:12px;color:#8888aa;flex:1;"></span>
<button id="library-issues-rename-all" type="button" disabled>Rename All</button> <button id="library-issues-rename-all" type="button" disabled>Rename ID Fixes</button>
<button id="library-issues-export" type="button" disabled>Export JSON</button>
<button id="library-issues-modal-done" type="button">Close</button> <button id="library-issues-modal-done" type="button">Close</button>
</div> </div>
</div> </div>
@@ -801,8 +825,13 @@
</div> </div>
</div> </div>
<script src="options-shared.js"></script>
<script src="options-cache.js"></script> <script src="options-cache.js"></script>
<script src="options-dupe-review.js"></script> <script src="options-dupe-review.js"></script>
<script src="options-library-issues.js"></script>
<script src="options-diagnostics.js"></script>
<script src="options-profiles.js"></script>
<script src="options-rules-editors.js"></script>
<script src="options.js"></script> <script src="options.js"></script>
</body> </body>
</html> </html>
File diff suppressed because it is too large Load Diff
View File
+1
View File
@@ -14,6 +14,7 @@
<button id="mode-cache" type="button" data-mode="cache" title="CACHE: use cache.json">CACHE</button> <button id="mode-cache" type="button" data-mode="cache" title="CACHE: use cache.json">CACHE</button>
</div> </div>
<button id="pause-scan" type="button" title="Pause scanning"></button> <button id="pause-scan" type="button" title="Pause scanning"></button>
<button id="open-bulk" title="Bulk ID Check">📋</button>
<button id="open-options" title="Options"></button> <button id="open-options" title="Options"></button>
</div> </div>
</div> </div>
+21
View File
@@ -285,13 +285,26 @@ function renderResult(r) {
$undoBtn.style.display = (settings && settings.enableDelete && settings.deleteMode === "trash") ? "" : "none"; $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) { 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; } if (settings && settings.scanPaused) { renderPausedState(); return; }
setStatus("Scanning…", "loading"); setStatus("Scanning…", "loading");
showSkeleton(2); showSkeleton(2);
$deleteBtn.style.display = "none"; $deleteBtn.style.display = "none";
$undoBtn.style.display = "none"; $undoBtn.style.display = "none";
chrome.runtime.sendMessage({ type: "check-tab", force }, (r) => { chrome.runtime.sendMessage({ type: "check-tab", force }, (r) => {
if (myId !== _currentSearchId) return; // stale — newer search started
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
setStatus("error: " + chrome.runtime.lastError.message, "err"); setStatus("error: " + chrome.runtime.lastError.message, "err");
$output.innerHTML = ""; $output.innerHTML = "";
@@ -443,6 +456,9 @@ let manualMode = false; // true while popup is showing manual-search results
function runManualSearch() { function runManualSearch() {
const raw = $searchInput.value.trim(); const raw = $searchInput.value.trim();
if (!raw) return; 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; } if (settings && settings.scanPaused) { renderPausedState(); return; }
manualMode = true; manualMode = true;
setStatus(`Searching ${raw}`, "loading"); setStatus(`Searching ${raw}`, "loading");
@@ -456,6 +472,7 @@ function runManualSearch() {
id: raw, id: raw,
quick: !!(settings && settings.quickMode), quick: !!(settings && settings.quickMode),
}, (r) => { }, (r) => {
if (myId !== _currentSearchId) return; // stale — newer search started
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
setStatus("error: " + chrome.runtime.lastError.message, "err"); setStatus("error: " + chrome.runtime.lastError.message, "err");
$output.innerHTML = ""; $output.innerHTML = "";
@@ -560,6 +577,10 @@ document.getElementById("recheck").addEventListener("click", async () => {
else runCheck(true); else runCheck(true);
}); });
document.getElementById("open-options").addEventListener("click", () => chrome.runtime.openOptionsPage()); document.getElementById("open-options").addEventListener("click", () => chrome.runtime.openOptionsPage());
document.getElementById("open-bulk").addEventListener("click", () => {
chrome.runtime.sendMessage({ type: "open-bulk-check" });
window.close();
});
$modeLive.addEventListener("click", () => setSearchMode("live")); $modeLive.addEventListener("click", () => setSearchMode("live"));
$modeCache.addEventListener("click", () => setSearchMode("cache")); $modeCache.addEventListener("click", () => setSearchMode("cache"));
$pauseScan.addEventListener("click", () => setScanPaused(!(settings && settings.scanPaused))); $pauseScan.addEventListener("click", () => setScanPaused(!(settings && settings.scanPaused)));
+50
View File
@@ -0,0 +1,50 @@
// Shared JAV-ID extraction primitives.
// Loaded via importScripts() in the background service worker, and listed in
// manifest content_scripts[] BEFORE content.js so both contexts see RCJAV_IDS.
// Re-injection (chrome.scripting.executeScript) is safe — reassigning the
// namespace each call is idempotent.
(function (root) {
// Optional trailing letter (e.g. IBW-902z) is absorbed but not part of the ID.
// rc-jav's index drops trailing letters too, so the query "IBW-902" finds either.
const ID_RE_DASHED = /\b([A-Za-z][A-Za-z0-9]{1,})-(\d{2,7})[a-zA-Z]?\b/;
const ID_RE_UNDASHED = /\b([A-Za-z][A-Za-z0-9]{1,})(\d{3,5})[a-zA-Z]?\b/;
// Built-in studio normalizers — applied BEFORE the generic regex.
// User normalizers (settings.idNormalizers) are tried before these.
const BUILTIN_ID_NORMALIZERS = [
// FC2-PPV in any dash configuration: FC2PPV12345, FC2-PPV12345, FC2-PPV-12345
{ re: /\bFC2-?PPV-?(\d{4,})\b/i, fmt: "FC2-PPV-$1" },
// Some sites display FC2 IDs without the PPV segment: FC2-1841460.
{ re: /\bFC2-(\d{4,})\b/i, fmt: "FC2-PPV-$1" },
];
function applyNormalizers(text, userList) {
const all = [...(userList || []), ...BUILTIN_ID_NORMALIZERS];
for (const n of all) {
let re;
try { re = n.re instanceof RegExp ? n.re : new RegExp(n.re, "i"); } catch { continue; }
const m = text.match(re);
if (m) return n.fmt.replace(/\$(\d)/g, (_, i) => m[+i] || "");
}
return null;
}
function normalizeId(text, userList) {
if (!text) return null;
const fromNormalizer = applyNormalizers(text, userList);
if (fromNormalizer) return fromNormalizer.toUpperCase();
let m = text.match(ID_RE_DASHED);
if (!m) m = text.match(ID_RE_UNDASHED);
if (!m) return null;
return `${m[1].toUpperCase()}-${m[2]}`;
}
root.RCJAV_IDS = {
BUILTIN_ID_NORMALIZERS,
ID_RE_DASHED,
ID_RE_UNDASHED,
applyNormalizers,
normalizeId,
};
})(typeof self !== "undefined" ? self : globalThis);