Sync working tree before initial Gitea push
- File reorg: popup/options/bulk-check moved to src/ subdirs - Shared modules: src/shared/id-extract.js, src/options/options-shared.js - Host updates: rcjav-host.py + register/install scripts - .gitignore expanded
This commit is contained in:
@@ -1,3 +1,7 @@
|
|||||||
host/__pycache__/
|
host/__pycache__/
|
||||||
host/logs/
|
host/logs/
|
||||||
host/state/
|
host/state/
|
||||||
|
*.bak
|
||||||
|
rclone-jav-library-issues-*.json
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|||||||
-122
@@ -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
|
|
||||||
+527
-379
File diff suppressed because it is too large
Load Diff
+8
-41
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"discord_webhook_url": "https://discord.com/api/webhooks/1507933272158507200/TEDqaLNBQn4dlSsG5kC2HP9IbTPg0trVWcy1WA46TdaLfZ1waMV82nTcNqVsfOY1Sw6u",
|
||||||
|
"pc_label": "001x100"
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"allowed_extension_ids": {
|
||||||
|
"rclone-jav": "dklpnjdfcoalaognbgbjoilklfjlnpnj",
|
||||||
|
"tabvault": "fdeddmkchldohogpogpahnkibifciflp"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
+788
-59
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "rclone-jav",
|
"name": "rclone-jav",
|
||||||
"version": "0.1.0",
|
"version": "0.1.52",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,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>`
|
||||||
@@ -23,6 +23,84 @@
|
|||||||
document.getElementById("run-diag").addEventListener("click", (event) =>
|
document.getElementById("run-diag").addEventListener("click", (event) =>
|
||||||
keepActionViewport(event.currentTarget, runDiagnostics)
|
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) =>
|
document.getElementById("host-status-run").addEventListener("click", (event) =>
|
||||||
keepActionViewport(event.currentTarget, runHostStatus)
|
keepActionViewport(event.currentTarget, runHostStatus)
|
||||||
);
|
);
|
||||||
@@ -119,7 +197,7 @@ async function runHostStatus() {
|
|||||||
async function runHostRepair() {
|
async function runHostRepair() {
|
||||||
const out = document.getElementById("host-status-results");
|
const out = document.getElementById("host-status-results");
|
||||||
clearNativeRepairCard();
|
clearNativeRepairCard();
|
||||||
out.innerHTML = '<div class="diag-row warn"><span class="icon">…</span><span class="name">repairing…</span><span class="detail">updating reachable native host manifest and user registration</span></div>';
|
out.innerHTML = '<div class="diag-row warn"><span class="icon">…</span><span class="name">repairing…</span><span class="detail">launching install-host.ps1 (UAC prompt will appear)</span></div>';
|
||||||
try {
|
try {
|
||||||
const r = await chrome.runtime.sendMessage({ type: "repair-host" });
|
const r = await chrome.runtime.sendMessage({ type: "repair-host" });
|
||||||
if (!r || !r.ok) {
|
if (!r || !r.ok) {
|
||||||
@@ -131,8 +209,7 @@ async function runHostRepair() {
|
|||||||
}
|
}
|
||||||
return { ok: false };
|
return { ok: false };
|
||||||
}
|
}
|
||||||
const checks = r.verification?.checks || [];
|
out.innerHTML = "";
|
||||||
renderDiagRows(out, checks, "repair verification");
|
|
||||||
renderCompletedNativeRepair(r);
|
renderCompletedNativeRepair(r);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -156,13 +233,12 @@ function renderCompletedNativeRepair(response) {
|
|||||||
if (!card || !out) return;
|
if (!card || !out) return;
|
||||||
card.style.display = "";
|
card.style.display = "";
|
||||||
const title = document.getElementById("native-repair-title");
|
const title = document.getElementById("native-repair-title");
|
||||||
if (title) title.textContent = "Registration repair completed";
|
if (title) title.textContent = "install-host.ps1 launched";
|
||||||
const regs = (response.registrations || []).filter((x) => x.status === "ok").length;
|
|
||||||
out.innerHTML = `
|
out.innerHTML = `
|
||||||
<div class="diag-row ok"><span class="icon">✓</span><span class="name">Repair applied</span><span class="detail">${escapeHtml(response.message || "native host registration repaired")}</span></div>
|
<div class="diag-row 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">Manifest</span><span class="detail">${escapeHtml(response.manifest_path || "")}</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">User registry</span><span class="detail">${escapeHtml(`${regs} HKCU registration entr${regs === 1 ? "y" : "ies"} updated`)}</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">Restart required</span><span class="detail">Fully close Brave, reopen it, reload the extension, then click Verify Registration. If Brave still blocks the host, run the registration steps shown by Diagnostics.</span></div>
|
<div class="diag-row warn"><span class="icon">!</span><span class="name">Next steps</span><span class="detail">1) Approve the UAC prompt. 2) Wait for the PowerShell window to print "Press Enter to close" and press Enter. 3) Fully close and reopen Brave. 4) Click Verify Registration.</span></div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,17 +276,19 @@ async function renderNativeMessagingFailure(response) {
|
|||||||
const extensionId = response?.extension_id || chrome.runtime.id;
|
const extensionId = response?.extension_id || chrome.runtime.id;
|
||||||
const paths = await getPackagedHostPaths();
|
const paths = await getPackagedHostPaths();
|
||||||
const installCommand = paths.installPs1
|
const installCommand = paths.installPs1
|
||||||
? `pwsh -ExecutionPolicy Bypass -File "${paths.installPs1}" -ExtensionId ${extensionId}`
|
? `pwsh -ExecutionPolicy Bypass -File "${paths.installPs1}"`
|
||||||
: `pwsh -ExecutionPolicy Bypass -File ".\\host\\install-host.ps1" -ExtensionId ${extensionId}`;
|
: `pwsh -ExecutionPolicy Bypass -File ".\\host\\install-host.ps1"`;
|
||||||
const registerCommand = paths.registerBat ? `"${paths.registerBat}"` : ".\\host\\register-host.bat";
|
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 cause = "This extension cannot launch the native messaging host yet.";
|
||||||
let fix = "Register the host for this extension ID, fully restart Brave, then verify registration.";
|
let fix = "Run register-host.bat once on this PC, fully restart Brave, then verify registration.";
|
||||||
if (kind === "forbidden") {
|
if (kind === "forbidden") {
|
||||||
cause = "Brave found the native host, but this extension ID is not allowed to launch it on this PC.";
|
cause = "Brave found the native host but the extension ID is not in its allowlist on this PC.";
|
||||||
fix = "This usually happens after loading the extension on another PC or under a different extension ID.";
|
fix = "Run register-host.bat to refresh the manifest from allowed-extension-ids.json.";
|
||||||
} else if (kind === "not_found") {
|
} else if (kind === "not_found") {
|
||||||
cause = "Brave could not find a registered native messaging host for com.rcjav.host on this PC.";
|
cause = "Brave could not find a registered native messaging host for com.rcjav.host on this PC.";
|
||||||
fix = "Run the registration script from the extension host folder.";
|
fix = "Run register-host.bat from the extension host folder.";
|
||||||
} else if (kind === "disconnected") {
|
} else if (kind === "disconnected") {
|
||||||
cause = "The native host started and then disconnected or crashed.";
|
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.";
|
fix = "After registration is fixed, run Runtime diagnostics again to check Python, rc-jav, and rclone.";
|
||||||
@@ -218,15 +296,17 @@ async function renderNativeMessagingFailure(response) {
|
|||||||
cause = "The native host did not respond before the timeout.";
|
cause = "The native host did not respond before the timeout.";
|
||||||
fix = "Restart Brave and check whether a scan or rclone command is stuck.";
|
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 = `
|
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">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 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 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 ok"><span class="icon">→</span><span class="name">Fix on this PC</span><span class="detail">${escapeHtml(fix)}</span></div>
|
||||||
<div class="diag-row info"><span class="icon">i</span><span class="name">Extension ID</span><span class="detail">${escapeHtml(extensionId)}</span></div>
|
|
||||||
<div class="diag-row info"><span class="icon">1</span><span class="name">Run register-host</span><span class="detail">
|
<div class="diag-row info"><span class="icon">1</span><span class="name">Run register-host</span><span class="detail">
|
||||||
<details open><summary>${escapeHtml(registerCommand)}</summary><pre>${escapeHtml(`Run ${registerCommand}\nWhen it asks for the extension ID, enter:\n${extensionId}\n\nPowerShell alternative:\n${installCommand}`)}</pre></details>
|
<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"><button type="button" data-copy="${escapeHtml(registerCommand)}" data-copy-label="Copy Script Path">Copy Script Path</button><button type="button" data-copy="${escapeHtml(extensionId)}" data-copy-label="Copy Extension ID">Copy Extension ID</button><button type="button" data-copy="${escapeHtml(installCommand)}" data-copy-label="Copy PowerShell Alternative">Copy PowerShell Alternative</button></span>
|
<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>
|
</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">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>
|
<div class="diag-row info"><span class="icon">3</span><span class="name">Verify</span><span class="detail"><span class="diag-action"><button type="button" data-verify-registration>Verify Registration</button></span></span></div>
|
||||||
@@ -238,6 +318,24 @@ async function renderNativeMessagingFailure(response) {
|
|||||||
setTimeout(() => { btn.textContent = btn.dataset.copyLabel || "Copy"; }, 1200);
|
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]")) {
|
for (const btn of out.querySelectorAll("button[data-verify-registration]")) {
|
||||||
btn.addEventListener("click", runHostStatus);
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2,16 +2,78 @@
|
|||||||
|
|
||||||
let lastLibraryIssues = null;
|
let lastLibraryIssues = null;
|
||||||
let _libraryIssuesDirty = false;
|
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) {
|
function renderLibraryIssues(r) {
|
||||||
const out = document.getElementById("library-issues-modal-body");
|
const out = document.getElementById("library-issues-modal-body");
|
||||||
const statusEl = document.getElementById("library-issues-results");
|
const statusEl = document.getElementById("library-issues-results");
|
||||||
const renameAllBtn = document.getElementById("library-issues-rename-all");
|
const renameAllBtn = document.getElementById("library-issues-rename-all");
|
||||||
|
const exportBtn = document.getElementById("library-issues-export");
|
||||||
const renameStatus = document.getElementById("library-issues-rename-status");
|
const renameStatus = document.getElementById("library-issues-rename-status");
|
||||||
|
|
||||||
if (!r || !r.ok) {
|
if (!r || !r.ok) {
|
||||||
lastLibraryIssues = null;
|
lastLibraryIssues = null;
|
||||||
renameAllBtn.disabled = true;
|
renameAllBtn.disabled = true;
|
||||||
|
exportBtn.disabled = true;
|
||||||
out.innerHTML = `<div class="li-empty" style="color:#f87171;">Error: ${escapeHtml(r?.error || "no response")}</div>`;
|
out.innerHTML = `<div class="li-empty" style="color:#f87171;">Error: ${escapeHtml(r?.error || "no response")}</div>`;
|
||||||
openModal("library-issues-modal");
|
openModal("library-issues-modal");
|
||||||
return;
|
return;
|
||||||
@@ -20,21 +82,45 @@ function renderLibraryIssues(r) {
|
|||||||
|
|
||||||
const brackets = r.bracket_names || [];
|
const brackets = r.bracket_names || [];
|
||||||
const nohyphens = r.nohyphen_names || [];
|
const nohyphens = r.nohyphen_names || [];
|
||||||
const total = brackets.length + nohyphens.length;
|
const missingRes = r.missing_resolution || [];
|
||||||
|
const noncanonicalRes = r.resolution_noncanonical || [];
|
||||||
|
const renameableTotal = brackets.length + nohyphens.length;
|
||||||
|
const total = renameableTotal + missingRes.length + noncanonicalRes.length;
|
||||||
|
const showRenameable = _libraryIssueTypeFilter === "all";
|
||||||
|
const showNoncanonical = _libraryIssueTypeFilter === "all" || _libraryIssueTypeFilter === "noncanonical";
|
||||||
|
const showMissing = _libraryIssueTypeFilter === "all" || _libraryIssueTypeFilter === "missing";
|
||||||
|
|
||||||
renameAllBtn.disabled = total === 0;
|
try { chrome.storage.local.set({ badge_library_issues_count: total }); } catch {}
|
||||||
|
|
||||||
|
renameAllBtn.disabled = !showRenameable || renameableTotal === 0;
|
||||||
|
renameAllBtn.title = renameableTotal
|
||||||
|
? "Rename only bracket-wrapped and no-hyphen ID fixes"
|
||||||
|
: "No bracket-wrapped or no-hyphen ID fixes to rename";
|
||||||
|
exportBtn.disabled = total === 0;
|
||||||
renameStatus.textContent = "";
|
renameStatus.textContent = "";
|
||||||
|
|
||||||
const parts = [];
|
const parts = [];
|
||||||
if (!total) {
|
if (!total) {
|
||||||
parts.push(`<div class="li-empty">✓ No library issues found. All filenames are canonical.</div>`);
|
parts.push(`<div class="li-empty">✓ No library issues found. All filenames are canonical.</div>`);
|
||||||
} else {
|
} else {
|
||||||
parts.push(`<div class="li-stats"><b>${total}</b> file${total !== 1 ? "s" : ""} with non-canonical names — <b>${brackets.length}</b> bracket-wrapped, <b>${nohyphens.length}</b> no-hyphen</div>`);
|
const typeButtons = [
|
||||||
|
["all", "All", total],
|
||||||
|
["noncanonical", "Noncanonical", noncanonicalRes.length],
|
||||||
|
["missing", "Missing res", missingRes.length],
|
||||||
|
].map(([type, label, count]) => (
|
||||||
|
`<button type="button" class="li-filter-chip li-type-chip${_libraryIssueTypeFilter === type ? " active" : ""}" data-type-filter="${escapeHtml(type)}">
|
||||||
|
<span>${escapeHtml(label)}</span><span>${Number(count).toLocaleString()}</span>
|
||||||
|
</button>`
|
||||||
|
)).join("");
|
||||||
|
parts.push(`<div class="li-stats with-filters">
|
||||||
|
<span><b>${total}</b> cache issue${total !== 1 ? "s" : ""} — <b>${brackets.length}</b> bracket-wrapped, <b>${nohyphens.length}</b> no-hyphen, <b>${missingRes.length}</b> missing resolution tag, <b>${noncanonicalRes.length}</b> noncanonical resolution</span>
|
||||||
|
<span class="li-filter-group">${typeButtons}</span>
|
||||||
|
</div>`);
|
||||||
|
|
||||||
const makeRow = (entry, tagClass, tagLabel) => {
|
const makeRow = (entry, tagClass, tagLabel) => {
|
||||||
const fname = entry.path.split("/").pop();
|
const fname = entry.path.split("/").pop();
|
||||||
const dir = entry.path.lastIndexOf("/") !== -1 ? entry.path.slice(0, entry.path.lastIndexOf("/") + 1) : "";
|
const dir = entry.path.lastIndexOf("/") !== -1 ? entry.path.slice(0, entry.path.lastIndexOf("/") + 1) : "";
|
||||||
return `<div class="li-row" data-remote="${escapeHtml(entry.remote)}" data-old="${escapeHtml(entry.path)}" data-new="${escapeHtml(dir + entry.canonical_name)}">
|
return `<div class="li-row" data-issue="${escapeHtml(entry.issue)}" data-remote="${escapeHtml(entry.remote)}" data-old="${escapeHtml(entry.path)}" data-new="${escapeHtml(dir + entry.canonical_name)}">
|
||||||
<span class="li-tag ${tagClass}">${tagLabel}</span>
|
<span class="li-tag ${tagClass}">${tagLabel}</span>
|
||||||
<div class="li-names">
|
<div class="li-names">
|
||||||
<span class="li-old" title="${escapeHtml(entry.path)}">${escapeHtml(fname)}</span>
|
<span class="li-old" title="${escapeHtml(entry.path)}">${escapeHtml(fname)}</span>
|
||||||
@@ -44,15 +130,50 @@ function renderLibraryIssues(r) {
|
|||||||
<button class="li-rename-btn" type="button">Rename</button>
|
<button class="li-rename-btn" type="button">Rename</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
};
|
};
|
||||||
|
const makeReportRow = (entry, tagLabel = "no res", tagClass = "missingres") => {
|
||||||
|
const fname = entry.filename || entry.path.split("/").pop();
|
||||||
|
return `<div class="li-row report-only" data-remote="${escapeHtml(entry.remote)}" data-old="${escapeHtml(entry.path)}">
|
||||||
|
<span class="li-tag ${tagClass}">${escapeHtml(tagLabel)}</span>
|
||||||
|
<div class="li-names">
|
||||||
|
<span class="li-old" title="${escapeHtml(entry.full_path || entry.path)}">${escapeHtml(fname)}</span>
|
||||||
|
<span class="li-new" title="${escapeHtml(_libraryIssueKindLabel(entry))}">${escapeHtml(entry.path)}</span>
|
||||||
|
</div>
|
||||||
|
<span class="li-sz">${escapeHtml(entry.size_human || "")}</span>
|
||||||
|
<span class="li-action-note">${escapeHtml(_libraryIssueKindLabel(entry))}</span>
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
if (brackets.length) {
|
if (showRenameable && brackets.length) {
|
||||||
parts.push(`<div class="li-section-head">Bracket-wrapped IDs (${brackets.length})</div>`);
|
parts.push(`<div class="li-section-head">Bracket-wrapped IDs (${brackets.length})</div>`);
|
||||||
parts.push(brackets.map((e) => makeRow(e, "bracket", "[ ]")).join(""));
|
parts.push(brackets.map((e) => makeRow(e, "bracket", "[ ]")).join(""));
|
||||||
}
|
}
|
||||||
if (nohyphens.length) {
|
if (showRenameable && nohyphens.length) {
|
||||||
parts.push(`<div class="li-section-head">No-hyphen IDs (${nohyphens.length})</div>`);
|
parts.push(`<div class="li-section-head">No-hyphen IDs (${nohyphens.length})</div>`);
|
||||||
parts.push(nohyphens.map((e) => makeRow(e, "nohyphen", "no hyphen")).join(""));
|
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("");
|
out.innerHTML = parts.join("");
|
||||||
@@ -65,6 +186,7 @@ function renderLibraryIssues(r) {
|
|||||||
out.querySelectorAll(".li-rename-btn").forEach((btn) => {
|
out.querySelectorAll(".li-rename-btn").forEach((btn) => {
|
||||||
btn.addEventListener("click", async () => {
|
btn.addEventListener("click", async () => {
|
||||||
const row = btn.closest(".li-row");
|
const row = btn.closest(".li-row");
|
||||||
|
if (!_canRenameIdFixRow(row)) return;
|
||||||
const remote = row.dataset.remote;
|
const remote = row.dataset.remote;
|
||||||
const oldPath = row.dataset.old;
|
const oldPath = row.dataset.old;
|
||||||
const newPath = row.dataset.new;
|
const newPath = row.dataset.new;
|
||||||
@@ -93,6 +215,19 @@ function renderLibraryIssues(r) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
out.querySelectorAll(".li-filter-chip").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
if (btn.dataset.typeFilter) {
|
||||||
|
_libraryIssueTypeFilter = btn.dataset.typeFilter || "all";
|
||||||
|
if (_libraryIssueTypeFilter !== "missing") _missingResolutionExtFilter = "all";
|
||||||
|
} else {
|
||||||
|
_missingResolutionExtFilter = btn.dataset.extFilter || "all";
|
||||||
|
_libraryIssueTypeFilter = "missing";
|
||||||
|
}
|
||||||
|
renderLibraryIssues(lastLibraryIssues);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("library-issues-run").addEventListener("click", async () => {
|
document.getElementById("library-issues-run").addEventListener("click", async () => {
|
||||||
@@ -107,18 +242,37 @@ document.getElementById("library-issues-rename-all").addEventListener("click", a
|
|||||||
const renameStatus = document.getElementById("library-issues-rename-status");
|
const renameStatus = document.getElementById("library-issues-rename-status");
|
||||||
const renameAllBtn = document.getElementById("library-issues-rename-all");
|
const renameAllBtn = document.getElementById("library-issues-rename-all");
|
||||||
|
|
||||||
// Collect pending renames (skip already-done or disabled rows)
|
// Collect only legacy ID-fix renames. Resolution hygiene rows are report-only
|
||||||
|
// until they have explicit, reviewed rename proposals.
|
||||||
const pending = rows.reduce((acc, row) => {
|
const pending = rows.reduce((acc, row) => {
|
||||||
const btn = row.querySelector(".li-rename-btn");
|
const btn = row.querySelector(".li-rename-btn");
|
||||||
if (!btn || btn.disabled) return acc;
|
if (!btn || btn.disabled || !_canRenameIdFixRow(row)) return acc;
|
||||||
acc.push({ row, remote: row.dataset.remote, old_path: row.dataset.old, new_path: row.dataset.new });
|
acc.push({ row, remote: row.dataset.remote, old_path: row.dataset.old, new_path: row.dataset.new });
|
||||||
return acc;
|
return acc;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!pending.length) return;
|
if (!pending.length) {
|
||||||
|
renameStatus.textContent = "No ID-fix rows are available to rename.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewLimit = 12;
|
||||||
|
const previewLines = pending.slice(0, previewLimit).map(({ old_path, new_path }) => (
|
||||||
|
`${old_path}\n -> ${new_path}`
|
||||||
|
));
|
||||||
|
const remaining = pending.length - previewLines.length;
|
||||||
|
const ok = confirm(
|
||||||
|
`Rename ${pending.length} ID-fix file(s)?\n\n`
|
||||||
|
+ previewLines.join("\n\n")
|
||||||
|
+ (remaining > 0 ? `\n\n...and ${remaining} more.` : "")
|
||||||
|
);
|
||||||
|
if (!ok) {
|
||||||
|
renameStatus.textContent = "Rename ID fixes cancelled.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
renameAllBtn.disabled = true;
|
renameAllBtn.disabled = true;
|
||||||
renameStatus.textContent = `Renaming ${pending.length} file(s)…`;
|
renameStatus.textContent = `Renaming ${pending.length} ID-fix file(s)…`;
|
||||||
|
|
||||||
const renames = pending.map(({ remote, old_path, new_path }) => ({ remote, old_path, new_path }));
|
const 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 res = await chrome.runtime.sendMessage({ type: "rename_files_batch", renames });
|
||||||
@@ -155,13 +309,48 @@ document.getElementById("library-issues-rename-all").addEventListener("click", a
|
|||||||
_libraryIssuesDirty = done > 0;
|
_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() {
|
function _closeLibraryIssues() {
|
||||||
closeModal("library-issues-modal");
|
closeModal("library-issues-modal");
|
||||||
if (_libraryIssuesDirty) {
|
if (_libraryIssuesDirty) {
|
||||||
_libraryIssuesDirty = false;
|
_libraryIssuesDirty = false;
|
||||||
chrome.runtime.sendMessage({ type: "library_issues" }, (r) => {
|
chrome.runtime.sendMessage({ type: "library_issues" }, (r) => {
|
||||||
if (!r || !r.ok) return;
|
if (!r || !r.ok) return;
|
||||||
const total = (r.bracket_names?.length || 0) + (r.nohyphen_names?.length || 0);
|
const total = (r.bracket_names?.length || 0)
|
||||||
|
+ (r.nohyphen_names?.length || 0)
|
||||||
|
+ (r.missing_resolution?.length || 0)
|
||||||
|
+ (r.resolution_noncanonical?.length || 0);
|
||||||
document.getElementById("library-issues-results").textContent = total
|
document.getElementById("library-issues-results").textContent = total
|
||||||
? `${total} library issue(s) found. Review window is open.`
|
? `${total} library issue(s) found. Review window is open.`
|
||||||
: "No library issues found.";
|
: "No library issues found.";
|
||||||
@@ -203,6 +392,17 @@ document.getElementById("library-issues-modal").addEventListener("click", (e) =>
|
|||||||
return "idle";
|
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) {
|
function _renderScanJob(r) {
|
||||||
if (!r || r.no_state) {
|
if (!r || r.no_state) {
|
||||||
scanJobOut.innerHTML = `<span style="color:#777;">no scan job recorded yet</span>`;
|
scanJobOut.innerHTML = `<span style="color:#777;">no scan job recorded yet</span>`;
|
||||||
@@ -215,9 +415,25 @@ document.getElementById("library-issues-modal").addEventListener("click", (e) =>
|
|||||||
const scope = (r.scope && r.scope.length) ? r.scope.join(", ") : "configured scan roots";
|
const scope = (r.scope && r.scope.length) ? r.scope.join(", ") : "configured scan roots";
|
||||||
const finished = r.finished_at || r.started_at || "";
|
const finished = r.finished_at || r.started_at || "";
|
||||||
const when = finished ? new Date(finished).toLocaleString() : "";
|
const when = finished ? new Date(finished).toLocaleString() : "";
|
||||||
const elapsed = r.elapsed_s != null ? `${Number(r.elapsed_s).toFixed(1)}s` : "";
|
let elapsed = r.elapsed_s != null ? _formatScanDuration(r.elapsed_s) : "";
|
||||||
const count = r.file_count != null ? `${Number(r.file_count).toLocaleString()} files` : "";
|
if (!elapsed && r.started_at) {
|
||||||
const summary = [mode, scope, count, elapsed].filter(Boolean).join(" · ");
|
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)
|
const jobs = (r.remote_jobs && r.remote_jobs.length)
|
||||||
? r.remote_jobs
|
? r.remote_jobs
|
||||||
: (r.remotes || []).map((remote, i) => ({
|
: (r.remotes || []).map((remote, i) => ({
|
||||||
@@ -242,14 +458,27 @@ document.getElementById("library-issues-modal").addEventListener("click", (e) =>
|
|||||||
Number.isFinite(j.skipped) && j.skipped ? `${j.skipped} skipped` : "",
|
Number.isFinite(j.skipped) && j.skipped ? `${j.skipped} skipped` : "",
|
||||||
].filter(Boolean).join(" · ");
|
].filter(Boolean).join(" · ");
|
||||||
return `<div class="scan-remote">
|
return `<div class="scan-remote">
|
||||||
<div><span style="color:#9dccff;">${escapeHtml(j.remote || "?")}</span> · <span>${escapeHtml(j.status || "queued")}</span></div>
|
<div class="scan-remote-head">
|
||||||
<div style="color:#777;">${escapeHtml(detail)}</div>
|
<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>` : ""}
|
${pct != null ? `<div class="scan-track"><div class="scan-fill" style="width:${pct}%"></div></div>` : ""}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join("");
|
}).join("");
|
||||||
scanJobOut.innerHTML = `
|
scanJobOut.innerHTML = `
|
||||||
<div style="color:#888;margin-bottom:6px;">${escapeHtml(jobLabel)}${when ? ` · ${escapeHtml(when)}` : ""}</div>
|
<div class="scan-job-title">
|
||||||
<div class="scan-job-head"><span class="scan-pill ${pillCls}">${escapeHtml(status)}</span><span>${escapeHtml(summary || "scan job")}</span></div>
|
<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>` : ""}
|
${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>` : ""}
|
${r.error ? `<div style="color:#faa;margin-bottom:6px;">${escapeHtml(r.error)}</div>` : ""}
|
||||||
${jobRows || `<div style="color:#777;">waiting for remote progress...</div>`}
|
${jobRows || `<div style="color:#777;">waiting for remote progress...</div>`}
|
||||||
@@ -482,4 +711,3 @@ document.getElementById("library-issues-modal").addEventListener("click", (e) =>
|
|||||||
_optScanTimer = setInterval(_pollOptProgress, 1500);
|
_optScanTimer = setInterval(_pollOptProgress, 1500);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
}[c]));
|
||||||
|
}
|
||||||
@@ -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 */
|
||||||
@@ -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 & 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 & 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 and fix non-canonical filenames in your library. (Bulk ID Check now opens in its own window from the popup.)</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,18 +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>
|
</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>
|
||||||
@@ -501,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">
|
||||||
@@ -573,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>
|
||||||
@@ -618,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>
|
||||||
@@ -742,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>
|
||||||
@@ -791,6 +825,7 @@
|
|||||||
</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-library-issues.js"></script>
|
||||||
@@ -22,8 +22,9 @@ const DEFAULT_TRIGGERS = {
|
|||||||
// ---------- sidebar nav ----------
|
// ---------- sidebar nav ----------
|
||||||
function activatePane(pane) {
|
function activatePane(pane) {
|
||||||
if (pane === "backup") pane = "paths";
|
if (pane === "backup") pane = "paths";
|
||||||
if (pane === "review") pane = "maintenance";
|
if (pane === "review") pane = "dupe-review";
|
||||||
const item = document.querySelector(`.side .item[data-pane="${pane}"]`) || document.querySelector('.side .item[data-pane="triggers"]');
|
if (pane === "maintenance") pane = "dupe-review";
|
||||||
|
const item = document.querySelector(`.side .item[data-pane="${pane}"]`) || document.querySelector('.side .item[data-pane="dupe-review"]');
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
document.querySelectorAll(".side .item").forEach((i) => i.classList.remove("active"));
|
document.querySelectorAll(".side .item").forEach((i) => i.classList.remove("active"));
|
||||||
item.classList.add("active");
|
item.classList.add("active");
|
||||||
@@ -39,6 +40,59 @@ for (const item of document.querySelectorAll(".side .item")) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- sidebar badges ----------
|
||||||
|
function fmtBadgeAge(hours) {
|
||||||
|
if (!Number.isFinite(hours) || hours < 0) return "";
|
||||||
|
if (hours < 1) return `${Math.max(1, Math.round(hours * 60))}m`;
|
||||||
|
if (hours < 48) return `${Math.round(hours)}h`;
|
||||||
|
return `${Math.round(hours / 24)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBadge(key, text, tone) {
|
||||||
|
const el = document.querySelector(`.side .side-badge[data-badge="${key}"]`);
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text || "";
|
||||||
|
el.classList.remove("warn", "fresh");
|
||||||
|
if (tone) el.classList.add(tone);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSidebarBadges() {
|
||||||
|
try {
|
||||||
|
const s = await chrome.storage.local.get([
|
||||||
|
"badge_dupe_count",
|
||||||
|
"badge_cache_age_hours",
|
||||||
|
"badge_cache_stale_hours",
|
||||||
|
"badge_library_issues_count",
|
||||||
|
]);
|
||||||
|
const dupe = Number(s.badge_dupe_count);
|
||||||
|
setBadge("dupe-count", dupe > 0 ? String(dupe) : "", dupe > 0 ? "warn" : "");
|
||||||
|
|
||||||
|
const age = Number(s.badge_cache_age_hours);
|
||||||
|
const stale = Number(s.badge_cache_stale_hours) || 24;
|
||||||
|
if (Number.isFinite(age)) {
|
||||||
|
setBadge("cache-age", fmtBadgeAge(age), age <= stale ? "fresh" : "warn");
|
||||||
|
} else {
|
||||||
|
setBadge("cache-age", "", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const issues = Number(s.badge_library_issues_count);
|
||||||
|
setBadge("library-issues-count", issues > 0 ? String(issues) : "", "");
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshSidebarBadges();
|
||||||
|
chrome.storage.onChanged.addListener((changes, area) => {
|
||||||
|
if (area !== "local") return;
|
||||||
|
if (
|
||||||
|
"badge_dupe_count" in changes ||
|
||||||
|
"badge_cache_age_hours" in changes ||
|
||||||
|
"badge_cache_stale_hours" in changes ||
|
||||||
|
"badge_library_issues_count" in changes
|
||||||
|
) {
|
||||||
|
refreshSidebarBadges();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function getActionScrollContainer(element) {
|
function getActionScrollContainer(element) {
|
||||||
for (let node = element?.parentElement; node; node = node.parentElement) {
|
for (let node = element?.parentElement; node; node = node.parentElement) {
|
||||||
const overflowY = getComputedStyle(node).overflowY;
|
const overflowY = getComputedStyle(node).overflowY;
|
||||||
@@ -144,6 +198,10 @@ async function load() {
|
|||||||
syncRadioChips();
|
syncRadioChips();
|
||||||
document.getElementById("trashDir").value = settings.trashDir || "cq:personal-files/.rclone-jav-trash";
|
document.getElementById("trashDir").value = settings.trashDir || "cq:personal-files/.rclone-jav-trash";
|
||||||
document.getElementById("rcjavPath").value = settings.rcjavPath || "";
|
document.getElementById("rcjavPath").value = settings.rcjavPath || "";
|
||||||
|
const dw = document.getElementById("discordWebhookUrl");
|
||||||
|
if (dw) dw.value = settings.discordWebhookUrl || "";
|
||||||
|
const pcl = document.getElementById("pcLabel");
|
||||||
|
if (pcl) pcl.value = settings.pcLabel || "";
|
||||||
renderProfiles(settings.profiles || []);
|
renderProfiles(settings.profiles || []);
|
||||||
updateSectionSummaries();
|
updateSectionSummaries();
|
||||||
syncDeletionControls();
|
syncDeletionControls();
|
||||||
@@ -180,11 +238,14 @@ async function save(e) {
|
|||||||
const deleteMode = document.getElementById("deleteModePerm").checked ? "permanent" : "trash";
|
const deleteMode = document.getElementById("deleteModePerm").checked ? "permanent" : "trash";
|
||||||
const trashDir = document.getElementById("trashDir").value.trim() || "cq:personal-files/.rclone-jav-trash";
|
const trashDir = document.getElementById("trashDir").value.trim() || "cq:personal-files/.rclone-jav-trash";
|
||||||
const rcjavPath = document.getElementById("rcjavPath").value.trim();
|
const rcjavPath = document.getElementById("rcjavPath").value.trim();
|
||||||
|
const discordWebhookUrl = (document.getElementById("discordWebhookUrl")?.value || "").trim();
|
||||||
|
const pcLabel = (document.getElementById("pcLabel")?.value || "").trim();
|
||||||
const payload = {
|
const payload = {
|
||||||
triggers, knownSitePatterns, quickMode, cacheStaleHours,
|
triggers, knownSitePatterns, quickMode, cacheStaleHours,
|
||||||
showOverlay, overlayPosition, overlayDuration, overlayGlow, overlayGlowColor, overlayGlowBlur, overlayGlowSpread, overlayGlowOpacity,
|
showOverlay, overlayPosition, overlayDuration, overlayGlow, overlayGlowColor, overlayGlowBlur, overlayGlowSpread, overlayGlowOpacity,
|
||||||
noMatchOverlay, noMatchPosition, noMatchDuration, noMatchGlow, noMatchGlowColor, noMatchGlowBlur, noMatchGlowSpread, noMatchGlowOpacity,
|
noMatchOverlay, noMatchPosition, noMatchDuration, noMatchGlow, noMatchGlowColor, noMatchGlowBlur, noMatchGlowSpread, noMatchGlowOpacity,
|
||||||
siteAdapters, idNormalizers, partPatterns, enableDelete, deleteMode, trashDir, rcjavPath,
|
siteAdapters, idNormalizers, partPatterns, enableDelete, deleteMode, trashDir, rcjavPath,
|
||||||
|
discordWebhookUrl, pcLabel,
|
||||||
profiles: readProfiles(),
|
profiles: readProfiles(),
|
||||||
activeProfile: existingSettings.activeProfile || "",
|
activeProfile: existingSettings.activeProfile || "",
|
||||||
scanPaused: !!existingSettings.scanPaused,
|
scanPaused: !!existingSettings.scanPaused,
|
||||||
@@ -193,6 +254,14 @@ async function save(e) {
|
|||||||
const saved = btn && btn.nextElementSibling;
|
const saved = btn && btn.nextElementSibling;
|
||||||
try {
|
try {
|
||||||
await chrome.storage.sync.set({ settings: payload });
|
await chrome.storage.sync.set({ settings: payload });
|
||||||
|
// Push alerts config to the host so its abnormal-disconnect / exception
|
||||||
|
// / write-error paths can post Discord webhooks too (catches failures the
|
||||||
|
// extension-side recordRpc never sees, like host being killed mid-write).
|
||||||
|
// Fire-and-forget — never block the SAVE on host availability.
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
type: "save-alerts-config",
|
||||||
|
discordWebhookUrl, pcLabel,
|
||||||
|
}).catch(() => {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// chrome.storage.sync has 8 KB/item + 100 KB total quota. Long adapter or
|
// chrome.storage.sync has 8 KB/item + 100 KB total quota. Long adapter or
|
||||||
// normalizer lists can blow it; without this try/catch the rejection is
|
// normalizer lists can blow it; without this try/catch the rejection is
|
||||||
@@ -316,8 +385,46 @@ document.addEventListener("change", (e) => {
|
|||||||
|
|
||||||
document.getElementById("export-settings").addEventListener("click", async () => {
|
document.getElementById("export-settings").addEventListener("click", async () => {
|
||||||
const { settings = {} } = await chrome.storage.sync.get("settings");
|
const { settings = {} } = await chrome.storage.sync.get("settings");
|
||||||
|
// Pull keep_ranking from the Python config.json via the host RPC. Lives
|
||||||
|
// outside chrome.storage so without this step Export drops VIP folders,
|
||||||
|
// size tolerance, format preference, and tiebreak prefs.
|
||||||
|
//
|
||||||
|
// FIX S-1 (bugs-fix-queue.md): the previous implementation silently swallowed
|
||||||
|
// RPC failures and wrote an export file with `_meta.host_config: {}` while
|
||||||
|
// status said "exported." That's a silent backup-data-loss bug — user trusts
|
||||||
|
// the backup; on later restore the missing keep_ranking surfaces only as a
|
||||||
|
// dismissable info row, and user loses VIP folders / format prefs.
|
||||||
|
//
|
||||||
|
// New behavior: if get-keep-ranking fails for any reason (host down, port
|
||||||
|
// broken, timeout, response not ok), BLOCK the export entirely and show a
|
||||||
|
// clear retry message. Backup must be all-or-nothing — partial backup files
|
||||||
|
// lying around are too easy to misuse months later when the user has
|
||||||
|
// forgotten which file was complete.
|
||||||
|
setBackupStatus("fetching keep ranking...", "");
|
||||||
|
let keepRanking = null;
|
||||||
|
try {
|
||||||
|
const r = await chrome.runtime.sendMessage({ type: "get-keep-ranking" });
|
||||||
|
if (!r || !r.ok) {
|
||||||
|
const reason = r?.error ? `: ${r.error}` : (r?.error_kind ? ` (${r.error_kind})` : "");
|
||||||
|
setBackupStatus(`export blocked — host could not return keep ranking${reason}. Confirm native host is running, then retry.`, "fail");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!r.keep_ranking || typeof r.keep_ranking !== "object") {
|
||||||
|
setBackupStatus("export blocked — host returned no keep_ranking payload. Run Setup → Test (host) to diagnose, then retry.", "fail");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
keepRanking = r.keep_ranking;
|
||||||
|
} catch (e) {
|
||||||
|
setBackupStatus(`export blocked — keep ranking fetch threw: ${e?.message || e}. Reload the extension or restart the host, then retry.`, "fail");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
_meta: { app: "rclonex", exported_at: new Date().toISOString(), version: 1 },
|
_meta: {
|
||||||
|
app: "rclone-jav",
|
||||||
|
exported_at: new Date().toISOString(),
|
||||||
|
version: 2, // v2: adds _meta.host_config.keep_ranking
|
||||||
|
host_config: { keep_ranking: keepRanking },
|
||||||
|
},
|
||||||
settings,
|
settings,
|
||||||
};
|
};
|
||||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
||||||
@@ -325,18 +432,85 @@ document.getElementById("export-settings").addEventListener("click", async () =>
|
|||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `rclonex-settings-${stamp}.json`;
|
a.download = `rclone-jav-settings-${stamp}.json`;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
setBackupStatus("exported.", "ok");
|
setBackupStatus("exported (settings + keep_ranking).", "ok");
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("import-settings").addEventListener("click", () => {
|
document.getElementById("import-settings").addEventListener("click", () => {
|
||||||
document.getElementById("import-file").click();
|
document.getElementById("import-file").click();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Discord webhook test (host-side) ----
|
||||||
|
document.getElementById("test-discord-host")?.addEventListener("click", async (e) => {
|
||||||
|
const btn = e.currentTarget;
|
||||||
|
const status = document.getElementById("discord-status");
|
||||||
|
const url = (document.getElementById("discordWebhookUrl")?.value || "").trim();
|
||||||
|
if (!url) {
|
||||||
|
if (status) { status.textContent = "Paste a webhook URL first."; status.style.color = "#888"; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pcLabel = (document.getElementById("pcLabel")?.value || "").trim();
|
||||||
|
// Push current URL+label to the host before testing so it reads fresh values.
|
||||||
|
await chrome.runtime.sendMessage({ type: "save-alerts-config", discordWebhookUrl: url, pcLabel });
|
||||||
|
btn.disabled = true;
|
||||||
|
const orig = btn.textContent;
|
||||||
|
btn.textContent = "Sending…";
|
||||||
|
if (status) { status.textContent = ""; status.style.color = "#888"; }
|
||||||
|
try {
|
||||||
|
const r = await chrome.runtime.sendMessage({ type: "test-host-alert" });
|
||||||
|
if (r?.ok) {
|
||||||
|
if (status) { status.textContent = "Host posted. Check Discord."; status.style.color = "#9be3b3"; }
|
||||||
|
} else {
|
||||||
|
if (status) { status.textContent = "Failed: " + (r?.error || "no response"); status.style.color = "#ff9097"; }
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (status) { status.textContent = "Failed: " + (err.message || err); status.style.color = "#ff9097"; }
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = orig;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Discord webhook test (extension-side) ----
|
||||||
|
document.getElementById("test-discord-webhook")?.addEventListener("click", async (e) => {
|
||||||
|
const btn = e.currentTarget;
|
||||||
|
const status = document.getElementById("discord-status");
|
||||||
|
const url = (document.getElementById("discordWebhookUrl")?.value || "").trim();
|
||||||
|
if (!url) {
|
||||||
|
if (status) { status.textContent = "Paste a webhook URL first."; status.className = "muted"; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Save current value first so background reads the latest URL.
|
||||||
|
const existing = await chrome.storage.sync.get("settings");
|
||||||
|
const merged = Object.assign({}, existing.settings || {}, {
|
||||||
|
discordWebhookUrl: url,
|
||||||
|
pcLabel: (document.getElementById("pcLabel")?.value || "").trim(),
|
||||||
|
});
|
||||||
|
await chrome.storage.sync.set({ settings: merged });
|
||||||
|
btn.disabled = true;
|
||||||
|
const orig = btn.textContent;
|
||||||
|
btn.textContent = "Sending…";
|
||||||
|
if (status) { status.textContent = ""; status.className = "muted"; }
|
||||||
|
try {
|
||||||
|
const r = await chrome.runtime.sendMessage({ type: "test-discord-webhook" });
|
||||||
|
if (r?.ok) {
|
||||||
|
if (status) { status.textContent = `Sent (HTTP ${r.status}). Check Discord.`; status.className = "muted"; status.style.color = "#9be3b3"; }
|
||||||
|
} else {
|
||||||
|
const detail = r?.error || `HTTP ${r?.status ?? "?"}`;
|
||||||
|
if (status) { status.textContent = `Failed: ${detail}`; status.className = "muted"; status.style.color = "#ff9097"; }
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (status) { status.textContent = "Failed: " + (err.message || err); status.style.color = "#ff9097"; }
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = orig;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Allowlist of settings keys with their expected primitive types. Imports
|
// Allowlist of settings keys with their expected primitive types. Imports
|
||||||
// containing any other key are dropped silently; primitives must match.
|
// containing any other key are dropped silently; primitives must match.
|
||||||
// Nested objects (triggers, siteAdapters[].*) get a recursive shallow check.
|
// Nested objects (triggers, siteAdapters[].*) get a recursive shallow check.
|
||||||
@@ -371,6 +545,8 @@ const SETTINGS_SCHEMA = {
|
|||||||
siteAdapters: "array",
|
siteAdapters: "array",
|
||||||
profiles: "array",
|
profiles: "array",
|
||||||
activeProfile: "string",
|
activeProfile: "string",
|
||||||
|
discordWebhookUrl: "string",
|
||||||
|
pcLabel: "string",
|
||||||
};
|
};
|
||||||
|
|
||||||
function _typeOf(v) {
|
function _typeOf(v) {
|
||||||
@@ -379,6 +555,47 @@ function _typeOf(v) {
|
|||||||
return typeof v;
|
return typeof v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _isPlainObject(v) {
|
||||||
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _isStringArray(v) {
|
||||||
|
return Array.isArray(v) && v.every((x) => typeof x === "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-key element validators for arrays of structured elements. Returns true
|
||||||
|
// if the element's shape is acceptable for downstream consumers; false means
|
||||||
|
// the element would crash content.js / id-extract.js / host RPC consumers and
|
||||||
|
// must be dropped.
|
||||||
|
//
|
||||||
|
// FIX M-1 (bugs-fix-queue.md): the prior sanitizer checked outer array type
|
||||||
|
// only, so an import with siteAdapters: [{host: 123, selector: []}] passed
|
||||||
|
// through to chrome.storage.sync. Later, content.js's tryAdapters called
|
||||||
|
// a.selector.split(",") which threw TypeError, breaking ID extraction on
|
||||||
|
// every web page until the user manually repaired settings.
|
||||||
|
//
|
||||||
|
// Each validator accepts older exports (missing optional fields are OK) but
|
||||||
|
// rejects shapes that don't match the consumer contract. Extra unknown fields
|
||||||
|
// on an element are tolerated — only the REQUIRED-for-consumer fields are
|
||||||
|
// checked, so v1 exports with extra metadata still import cleanly.
|
||||||
|
const ARRAY_ELEMENT_VALIDATORS = {
|
||||||
|
siteAdapters: (e) =>
|
||||||
|
_isPlainObject(e)
|
||||||
|
&& typeof e.host === "string"
|
||||||
|
&& typeof e.selector === "string",
|
||||||
|
idNormalizers: (e) =>
|
||||||
|
_isPlainObject(e)
|
||||||
|
&& (typeof e.re === "string" || e.re instanceof RegExp)
|
||||||
|
&& typeof e.fmt === "string",
|
||||||
|
partPatterns: (e) => typeof e === "string",
|
||||||
|
knownSitePatterns: (e) => typeof e === "string",
|
||||||
|
profiles: (e) =>
|
||||||
|
_isPlainObject(e)
|
||||||
|
&& typeof e.name === "string"
|
||||||
|
&& _isStringArray(e.source || [])
|
||||||
|
&& _isStringArray(e.target || []),
|
||||||
|
};
|
||||||
|
|
||||||
function sanitizeImportedSettings(incoming) {
|
function sanitizeImportedSettings(incoming) {
|
||||||
if (typeof incoming !== "object" || incoming === null || Array.isArray(incoming)) {
|
if (typeof incoming !== "object" || incoming === null || Array.isArray(incoming)) {
|
||||||
throw new Error("settings must be a JSON object");
|
throw new Error("settings must be a JSON object");
|
||||||
@@ -389,8 +606,34 @@ function sanitizeImportedSettings(incoming) {
|
|||||||
const expected = SETTINGS_SCHEMA[k];
|
const expected = SETTINGS_SCHEMA[k];
|
||||||
if (!expected) { dropped.push(k); continue; }
|
if (!expected) { dropped.push(k); continue; }
|
||||||
if (_typeOf(v) !== expected) { dropped.push(`${k}(wrong type)`); continue; }
|
if (_typeOf(v) !== expected) { dropped.push(`${k}(wrong type)`); continue; }
|
||||||
|
|
||||||
|
// Per-element validation for arrays whose elements have a required shape.
|
||||||
|
if (expected === "array" && ARRAY_ELEMENT_VALIDATORS[k]) {
|
||||||
|
const validator = ARRAY_ELEMENT_VALIDATORS[k];
|
||||||
|
const goodElements = [];
|
||||||
|
let badCount = 0;
|
||||||
|
v.forEach((el, idx) => {
|
||||||
|
if (validator(el)) {
|
||||||
|
goodElements.push(el);
|
||||||
|
} else {
|
||||||
|
badCount++;
|
||||||
|
// Cap per-element dropped lines so the import modal stays readable
|
||||||
|
// if someone imports a totally malformed file.
|
||||||
|
if (dropped.length < 30) dropped.push(`${k}[${idx}](malformed)`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (badCount > 0 && goodElements.length === 0) {
|
||||||
|
// Every element was bad — drop the key entirely so mergeSettings can
|
||||||
|
// fall back to defaults rather than persisting an empty (but typed)
|
||||||
|
// array that masks the underlying corruption.
|
||||||
|
dropped.push(`${k}(all ${v.length} elements malformed — falling back to defaults)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out[k] = goodElements;
|
||||||
|
} else {
|
||||||
out[k] = v;
|
out[k] = v;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return { sanitized: out, dropped };
|
return { sanitized: out, dropped };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,19 +644,34 @@ function closeImportModal() {
|
|||||||
closeModal("import-modal");
|
closeModal("import-modal");
|
||||||
}
|
}
|
||||||
|
|
||||||
function openImportModal(fileName, sanitized, dropped) {
|
function openImportModal(fileName, sanitized, dropped, keepRanking) {
|
||||||
pendingImport = { sanitized, dropped };
|
pendingImport = { sanitized, dropped, keepRanking };
|
||||||
document.getElementById("import-modal-subtitle").textContent = fileName || "settings JSON";
|
document.getElementById("import-modal-subtitle").textContent = fileName || "settings JSON";
|
||||||
|
const krRow = keepRanking
|
||||||
|
? `<div class="diag-row info"><span class="icon">i</span><span class="name">Keep Ranking</span><span class="detail">${escapeHtml(`${(keepRanking.priority_folders || []).length} VIP folder(s), ${(keepRanking.format_preference || []).length} format(s) — will overwrite Python config.json`)}</span></div>`
|
||||||
|
: `<div class="diag-row info"><span class="icon">i</span><span class="name">Keep Ranking</span><span class="detail">Not included in this export (file may be from older version)</span></div>`;
|
||||||
document.getElementById("import-modal-body").innerHTML = `
|
document.getElementById("import-modal-body").innerHTML = `
|
||||||
<div class="modal-help">Review this import before it replaces the current extension settings.</div>
|
<div class="modal-help">Review this import before it replaces the current extension settings.</div>
|
||||||
<div class="diag-row warn"><span class="icon">!</span><span class="name">Overwrite</span><span class="detail">Current settings will be replaced by ${escapeHtml(Object.keys(sanitized).length)} imported value(s).</span></div>
|
<div class="diag-row warn"><span class="icon">!</span><span class="name">Overwrite</span><span class="detail">Current settings will be replaced by ${escapeHtml(Object.keys(sanitized).length)} imported value(s).</span></div>
|
||||||
<div class="diag-row info"><span class="icon">i</span><span class="name">Profiles</span><span class="detail">${escapeHtml(Array.isArray(sanitized.profiles) ? `${sanitized.profiles.length} profile(s) in this import` : "No profile list in this import")}</span></div>
|
<div class="diag-row info"><span class="icon">i</span><span class="name">Profiles</span><span class="detail">${escapeHtml(Array.isArray(sanitized.profiles) ? `${sanitized.profiles.length} profile(s) in this import` : "No profile list in this import")}</span></div>
|
||||||
|
${krRow}
|
||||||
<div class="diag-row ${sanitized.enableDelete ? "warn" : "info"}"><span class="icon">${sanitized.enableDelete ? "!" : "i"}</span><span class="name">Deletion</span><span class="detail">${escapeHtml(sanitized.enableDelete ? `Enabled in imported settings (${sanitized.deleteMode || "trash"} mode)` : "Not enabled by this import")}</span></div>
|
<div class="diag-row ${sanitized.enableDelete ? "warn" : "info"}"><span class="icon">${sanitized.enableDelete ? "!" : "i"}</span><span class="name">Deletion</span><span class="detail">${escapeHtml(sanitized.enableDelete ? `Enabled in imported settings (${sanitized.deleteMode || "trash"} mode)` : "Not enabled by this import")}</span></div>
|
||||||
${dropped.length ? `<div class="diag-row warn"><span class="icon">!</span><span class="name">Ignored keys</span><span class="detail">${escapeHtml(`${dropped.length}: ${dropped.slice(0, 8).join(", ")}${dropped.length > 8 ? "..." : ""}`)}</span></div>` : `<div class="diag-row ok"><span class="icon">✓</span><span class="name">Schema</span><span class="detail">All imported keys are recognized.</span></div>`}
|
${dropped.length ? `<div class="diag-row warn"><span class="icon">!</span><span class="name">Ignored keys</span><span class="detail">${escapeHtml(`${dropped.length}: ${dropped.slice(0, 8).join(", ")}${dropped.length > 8 ? "..." : ""}`)}</span></div>` : `<div class="diag-row ok"><span class="icon">✓</span><span class="name">Schema</span><span class="detail">All imported keys are recognized.</span></div>`}
|
||||||
`;
|
`;
|
||||||
openModal("import-modal");
|
openModal("import-modal");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _extractKeepRanking(data) {
|
||||||
|
// v2 exports park keep_ranking under _meta.host_config.keep_ranking. Older
|
||||||
|
// (v1) files have no such field — return null so the import modal flags it.
|
||||||
|
const kr = data?._meta?.host_config?.keep_ranking;
|
||||||
|
if (!kr || typeof kr !== "object" || Array.isArray(kr)) return null;
|
||||||
|
// Light validation — host RPC re-validates before writing config.json.
|
||||||
|
if (kr.format_preference != null && !Array.isArray(kr.format_preference)) return null;
|
||||||
|
if (kr.priority_folders != null && !Array.isArray(kr.priority_folders)) return null;
|
||||||
|
return kr;
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById("import-file").addEventListener("change", async (e) => {
|
document.getElementById("import-file").addEventListener("change", async (e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -425,7 +683,8 @@ document.getElementById("import-file").addEventListener("change", async (e) => {
|
|||||||
if (Object.keys(sanitized).length === 0) {
|
if (Object.keys(sanitized).length === 0) {
|
||||||
throw new Error("no recognized settings keys in file");
|
throw new Error("no recognized settings keys in file");
|
||||||
}
|
}
|
||||||
openImportModal(file.name, sanitized, dropped);
|
const keepRanking = _extractKeepRanking(data);
|
||||||
|
openImportModal(file.name, sanitized, dropped, keepRanking);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setBackupStatus("import failed: " + err.message, "fail");
|
setBackupStatus("import failed: " + err.message, "fail");
|
||||||
}
|
}
|
||||||
@@ -434,12 +693,21 @@ document.getElementById("import-file").addEventListener("change", async (e) => {
|
|||||||
|
|
||||||
document.getElementById("import-modal-confirm").addEventListener("click", async () => {
|
document.getElementById("import-modal-confirm").addEventListener("click", async () => {
|
||||||
if (!pendingImport) return;
|
if (!pendingImport) return;
|
||||||
const { sanitized, dropped } = pendingImport;
|
const { sanitized, dropped, keepRanking } = pendingImport;
|
||||||
try {
|
try {
|
||||||
await chrome.storage.sync.set({ settings: sanitized });
|
await chrome.storage.sync.set({ settings: sanitized });
|
||||||
|
let krNote = "";
|
||||||
|
if (keepRanking) {
|
||||||
|
try {
|
||||||
|
const r = await chrome.runtime.sendMessage({ type: "save-keep-ranking", keep_ranking: keepRanking });
|
||||||
|
krNote = r?.ok ? " + keep ranking" : ` (keep ranking failed: ${r?.error || "no response"})`;
|
||||||
|
} catch (e) {
|
||||||
|
krNote = ` (keep ranking failed: ${e.message || e})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
chrome.runtime.sendMessage({ type: "settings-changed" });
|
chrome.runtime.sendMessage({ type: "settings-changed" });
|
||||||
closeImportModal();
|
closeImportModal();
|
||||||
setBackupStatus(`imported${dropped.length ? ` (${dropped.length} key(s) dropped)` : ""}. reloading...`, "ok");
|
setBackupStatus(`imported${krNote}${dropped.length ? ` (${dropped.length} key(s) dropped)` : ""}. reloading...`, "ok");
|
||||||
setTimeout(() => location.reload(), 600);
|
setTimeout(() => location.reload(), 600);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setBackupStatus("import failed: " + (err.message || String(err)), "fail");
|
setBackupStatus("import failed: " + (err.message || String(err)), "fail");
|
||||||
@@ -529,8 +797,25 @@ function updateActivityFilterButtons() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateActivityCounts() {
|
||||||
|
const entries = recentActivityEntries || [];
|
||||||
|
const counts = { all: entries.length, hit: 0, miss: 0, other: 0 };
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.outcome === "hit") counts.hit++;
|
||||||
|
else if (e.outcome === "miss") counts.miss++;
|
||||||
|
else if (e.outcome !== "no_id") counts.other++;
|
||||||
|
}
|
||||||
|
document.querySelectorAll("#activity-filters [data-activity-count]").forEach((el) => {
|
||||||
|
const key = el.dataset.activityCount;
|
||||||
|
const n = counts[key] ?? 0;
|
||||||
|
// Cap display at 999 so the 3-digit slot never overflows.
|
||||||
|
el.textContent = n > 999 ? "999+" : String(n);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderActivity(entries = recentActivityEntries) {
|
function renderActivity(entries = recentActivityEntries) {
|
||||||
recentActivityEntries = entries || [];
|
recentActivityEntries = entries || [];
|
||||||
|
updateActivityCounts();
|
||||||
const out = document.getElementById("activity-results");
|
const out = document.getElementById("activity-results");
|
||||||
if (!recentActivityEntries.length) {
|
if (!recentActivityEntries.length) {
|
||||||
out.innerHTML = `<span style="color:#777;">no recent activity yet</span>`;
|
out.innerHTML = `<span style="color:#777;">no recent activity yet</span>`;
|
||||||
@@ -1003,7 +1288,7 @@ document.getElementById("check-rcjav-path").addEventListener("click", async () =
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const { optionsActivePane, pendingNativeSetupIssue } = await chrome.storage.local.get(["optionsActivePane", "pendingNativeSetupIssue"]);
|
const { optionsActivePane, pendingNativeSetupIssue } = await chrome.storage.local.get(["optionsActivePane", "pendingNativeSetupIssue"]);
|
||||||
activatePane(optionsActivePane || "triggers");
|
activatePane(optionsActivePane || "dupe-review");
|
||||||
await load();
|
await load();
|
||||||
if (pendingNativeSetupIssue) {
|
if (pendingNativeSetupIssue) {
|
||||||
activatePane("diagnostics");
|
activatePane("diagnostics");
|
||||||
@@ -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 = "";
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user