Add cross-browser popup status handling
This commit is contained in:
parent
9c65a61ebb
commit
7e17470356
38 changed files with 1242 additions and 199 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -534,3 +534,4 @@ node_modules/
|
||||||
dist/
|
dist/
|
||||||
coverage/
|
coverage/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
release/
|
||||||
|
|
|
||||||
21
DESIGN.md
21
DESIGN.md
|
|
@ -61,26 +61,23 @@ WebGPU inference may be investigated later, but CPU/WASM should be preferred ini
|
||||||
|
|
||||||
## Site Integration
|
## Site Integration
|
||||||
|
|
||||||
The extension will initially support only explicitly implemented websites rather than attempting to generically interpret arbitrary web pages.
|
The extension supports websites through declarative, user-configurable site definitions rather than site-specific code. A definition supplies URL patterns, page markers, post selectors, text selectors, and stable-ID sources. The content script is loaded broadly but exits without examining content unless a definition matches.
|
||||||
|
|
||||||
Each supported website implements a common post parser interface.
|
Each active definition is executed by the same generic discovery engine.
|
||||||
|
|
||||||
Conceptually:
|
Conceptually:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
IPostParser
|
Site definition
|
||||||
|
|
|
|
||||||
+-- FacebookPostParser
|
+-- Generic discovery engine
|
||||||
+-- TwitterPostParser
|
|
||||||
+-- RedditPostParser
|
|
||||||
```
|
```
|
||||||
|
|
||||||
A parser is responsible for:
|
A definition is responsible for declaring:
|
||||||
|
|
||||||
* Detecting posts and comments.
|
* Where posts and comments appear.
|
||||||
* Extracting their textual content.
|
* Which rendered text should be extracted.
|
||||||
* Providing a stable or locally generated identifier.
|
* Which attributes or links provide stable identifiers.
|
||||||
* Maintaining a reference to the associated DOM element.
|
|
||||||
|
|
||||||
A normalized post might resemble:
|
A normalized post might resemble:
|
||||||
|
|
||||||
|
|
@ -348,6 +345,6 @@ Filtering policy
|
||||||
Page presentation
|
Page presentation
|
||||||
```
|
```
|
||||||
|
|
||||||
Site parsers should not contain ML logic. The inference backend should not understand individual websites. The classifier should only determine toxicity probability, while filtering policy determines whether that probability warrants hiding content.
|
Site definitions and their discovery engine should not contain ML logic. The inference backend should not understand individual websites. The classifier should only determine toxicity probability, while filtering policy determines whether that probability warrants hiding content.
|
||||||
|
|
||||||
This separation should allow website support, ML implementation, and user-facing filtering behavior to evolve independently.
|
This separation should allow website support, ML implementation, and user-facing filtering behavior to evolve independently.
|
||||||
|
|
|
||||||
93
README.md
93
README.md
|
|
@ -2,6 +2,19 @@
|
||||||
|
|
||||||
VibeGuard is a local-first browser extension that hides toxic social-media posts. Content is classified on-device; post text is never sent to a classification service.
|
VibeGuard is a local-first browser extension that hides toxic social-media posts. Content is classified on-device; post text is never sent to a classification service.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Local toxicity classification using Transformers.js and a quantized ONNX model.
|
||||||
|
- Configurable toxicity threshold and filtering mode: collapse posts with a Show button or hide them completely.
|
||||||
|
- Optional toxicity scores on filtered-post placeholders.
|
||||||
|
- Declarative site definitions with URL patterns, selectors, and stable identifiers.
|
||||||
|
- Built-in support for the official Mastodon web interface.
|
||||||
|
- Import and export of custom site definitions as JSON.
|
||||||
|
- Toolbar popup with current-site status, matched definition, pause/resume control, and settings access.
|
||||||
|
- Firefox Manifest V2 and Chromium Manifest V3 builds.
|
||||||
|
|
||||||
|
The content script is loaded broadly so it can support navigation and dynamically rendered pages, but it does not inspect page content until a validated site definition matches the current page. Site definitions are data, not executable user code.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
@ -34,8 +47,86 @@ Preparation writes the Transformers.js-compatible files and `model-manifest.json
|
||||||
|
|
||||||
If the model uses generic labels such as `LABEL_0` and `LABEL_1`, pass `--toxic-index` and `--non-toxic-index` to `prepare`; the command refuses to guess an ambiguous mapping.
|
If the model uses generic labels such as `LABEL_0` and `LABEL_1`, pass `--toxic-index` and `--non-toxic-index` to `prepare`; the command refuses to guess an ambiguous mapping.
|
||||||
|
|
||||||
The first release targets Reddit, X/Twitter, and Facebook. Site selectors are isolated under `src/content/parsers/` because these sites frequently change their DOM structures.
|
VibeGuard injects a lightweight content script on ordinary web pages, but reads and processes content only when a validated site definition matches the page. The extension includes a definition for the official Mastodon web interface; custom definitions and overrides are managed as JSON in the options page. Definitions are declarative selectors and URL patterns, never executable code.
|
||||||
|
|
||||||
## Runtime architecture
|
## Runtime architecture
|
||||||
|
|
||||||
Firefox uses a persistent MV2 background page and inference worker. Chromium uses an MV3 service worker as a router, an offscreen document, and a worker-backed classifier. Both builds share one bounded, prioritized inference queue and in-memory text-result cache.
|
Firefox uses a persistent MV2 background page and inference worker. Chromium uses an MV3 service worker as a router, an offscreen document, and a worker-backed classifier. Both builds share one bounded, prioritized inference queue and in-memory text-result cache.
|
||||||
|
|
||||||
|
The high-level processing path is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Supported page
|
||||||
|
-> validated site definition
|
||||||
|
-> generic content discovery
|
||||||
|
-> shared inference queue
|
||||||
|
-> local classifier
|
||||||
|
-> threshold and filtering mode
|
||||||
|
```
|
||||||
|
|
||||||
|
The model is loaded once by the shared inference backend for each browser context. Content scripts send normalized text to the background runtime and apply the returned score to the matching DOM element. Results are cached in memory to avoid repeating work when dynamic sites recreate elements.
|
||||||
|
|
||||||
|
## Options page
|
||||||
|
|
||||||
|
Open the extension’s options page to change the threshold, filtering mode, and score display. The Site definitions section accepts a JSON object containing `customDefinitions` and `disabledDefinitionIds`. Definitions can be imported from or exported to a file, and are validated before they are saved.
|
||||||
|
|
||||||
|
The options page uses the locally bundled [Bulma CSS](https://bulma.io/) v1.0.3 stylesheet. No options-page styling or runtime dependency is loaded from a CDN.
|
||||||
|
|
||||||
|
Click the toolbar icon to open the VibeGuard popup. It reports whether the current page is supported, names the matched site definition, and provides a persistent pause/resume control for that definition. Pausing immediately stops filtering in matching open tabs and removes VibeGuard’s current placeholders; resuming restarts discovery without requiring a page reload.
|
||||||
|
|
||||||
|
## Browser permissions
|
||||||
|
|
||||||
|
VibeGuard requests the following permissions:
|
||||||
|
|
||||||
|
- `storage` to save settings and definition configuration.
|
||||||
|
- `tabs` to associate inference requests with browser tabs.
|
||||||
|
- `<all_urls>` host access so supported sites can be detected and filtered.
|
||||||
|
- `offscreen` in the Chromium Manifest V3 build to host the long-lived inference document.
|
||||||
|
|
||||||
|
## Model and third-party licenses
|
||||||
|
|
||||||
|
The packaged toxicity model is distributed under the Apache License 2.0. Its source revision, label mapping, sequence length, and quantization details are recorded in `public/models/toxicity/model-manifest.json`.
|
||||||
|
|
||||||
|
VibeGuard also bundles [Bulma CSS v1.0.3](https://github.com/jgthms/bulma), which is distributed under the MIT License. The project’s own code is licensed under the terms in [LICENSE](LICENSE).
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
| Path | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/content/` | Generic site discovery and DOM filtering. |
|
||||||
|
| `src/background/` | Firefox and Chromium background runtimes. |
|
||||||
|
| `src/inference/` | Worker, classifier, queue, and model metadata. |
|
||||||
|
| `src/options/` | Options-page behavior and VibeGuard-specific styling. |
|
||||||
|
| `src/shared/` | Settings, site-definition validation, types, and shared utilities. |
|
||||||
|
| `public/` | Browser manifests, options HTML, bundled CSS, and model files. |
|
||||||
|
| `tests/` | Unit tests for queues, filtering, definitions, hashing, and model metadata. |
|
||||||
|
|
||||||
|
## Testing and packaging
|
||||||
|
|
||||||
|
Run the type checker and test suite before building a package:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the browser-specific packages with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build:firefox
|
||||||
|
npm run build:chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated directories are `dist/firefox/` and `dist/chromium/`. Each contains the browser manifest, bundled JavaScript, the local `bulma.css` asset, and the model files required by that build. Load the generated directory as a temporary/unpacked extension during development.
|
||||||
|
|
||||||
|
To build versioned install packages for both browsers, run the shell packaging script from any directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script runs both browser builds and writes `release/vibeguard-firefox-<version>.zip` and `release/vibeguard-chromium-<version>.zip`. The archives contain the generated extension files at their root and are ignored by Git.
|
||||||
|
|
||||||
|
### Logo and icons
|
||||||
|
|
||||||
|
Place the source logo at `public/logo.png`. The options page displays the generated 64 px version, and `build.sh` uses ImageMagick to generate the browser icon sizes `16`, `32`, `48`, `64`, `96`, and `128` under `icons/` in each release package. Install ImageMagick before running the packaging script; it accepts either the `magick` or `convert` command.
|
||||||
|
|
|
||||||
113
build.sh
Executable file
113
build.sh
Executable file
|
|
@ -0,0 +1,113 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
release_dir="$script_dir/release"
|
||||||
|
logo_source="$script_dir/public/logo.png"
|
||||||
|
firefox_manifest="$script_dir/public/manifest.firefox.json"
|
||||||
|
chromium_manifest="$script_dir/public/manifest.chromium.json"
|
||||||
|
|
||||||
|
for command_name in npm zip; do
|
||||||
|
if ! command -v "$command_name" >/dev/null 2>&1; then
|
||||||
|
echo "$command_name is required to build release packages." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if command -v magick >/dev/null 2>&1; then
|
||||||
|
image_tool=(magick)
|
||||||
|
elif command -v convert >/dev/null 2>&1; then
|
||||||
|
image_tool=(convert)
|
||||||
|
else
|
||||||
|
echo "ImageMagick (magick or convert) is required to process public/logo.png." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$logo_source" ]]; then
|
||||||
|
echo "Logo not found at $logo_source. Place the source PNG there before building." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for manifest in "$firefox_manifest" "$chromium_manifest"; do
|
||||||
|
if [[ ! -f "$manifest" ]]; then
|
||||||
|
echo "Manifest not found: $manifest" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
manifest_version() {
|
||||||
|
local manifest="$1"
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
jq -er '.version // empty' "$manifest"
|
||||||
|
elif command -v python3 >/dev/null 2>&1; then
|
||||||
|
python3 -c 'import json, sys; print(json.load(open(sys.argv[1], encoding="utf-8")).get("version", ""))' "$manifest"
|
||||||
|
else
|
||||||
|
echo "jq or python3 is required to read manifest versions." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
firefox_version="$(manifest_version "$firefox_manifest")"
|
||||||
|
chromium_version="$(manifest_version "$chromium_manifest")"
|
||||||
|
|
||||||
|
if [[ -z "$firefox_version" || -z "$chromium_version" ]]; then
|
||||||
|
echo "Both manifests must contain a version." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$firefox_version" != "$chromium_version" ]]; then
|
||||||
|
echo "Firefox and Chromium manifest versions differ: $firefox_version vs $chromium_version" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
version="$firefox_version"
|
||||||
|
|
||||||
|
echo "Building Firefox package..."
|
||||||
|
(cd "$script_dir" && npm run build:firefox)
|
||||||
|
echo "Building Chromium package..."
|
||||||
|
(cd "$script_dir" && npm run build:chromium)
|
||||||
|
|
||||||
|
for browser in firefox chromium; do
|
||||||
|
dist_dir="$script_dir/dist/$browser"
|
||||||
|
if [[ ! -f "$dist_dir/manifest.json" ]]; then
|
||||||
|
echo "Build output is missing manifest.json: $dist_dir" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
process_icons() {
|
||||||
|
local browser="$1"
|
||||||
|
local dist_dir="$script_dir/dist/$browser"
|
||||||
|
local icon_dir="$dist_dir/icons"
|
||||||
|
|
||||||
|
mkdir -p "$icon_dir"
|
||||||
|
for size in 16 32 48 64 96 128; do
|
||||||
|
"${image_tool[@]}" "$logo_source" \
|
||||||
|
-resize "${size}x${size}^" \
|
||||||
|
-gravity center \
|
||||||
|
-background none \
|
||||||
|
-extent "${size}x${size}" \
|
||||||
|
"$icon_dir/logo${size}.png"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
process_icons firefox
|
||||||
|
process_icons chromium
|
||||||
|
|
||||||
|
mkdir -p "$release_dir"
|
||||||
|
|
||||||
|
package_browser() {
|
||||||
|
local browser="$1"
|
||||||
|
local dist_dir="$script_dir/dist/$browser"
|
||||||
|
local archive="$release_dir/vibeguard-$browser-$version.zip"
|
||||||
|
|
||||||
|
rm -f "$archive"
|
||||||
|
(
|
||||||
|
cd "$dist_dir"
|
||||||
|
zip -q -9 -r "$archive" .
|
||||||
|
)
|
||||||
|
echo "Built $archive"
|
||||||
|
}
|
||||||
|
|
||||||
|
package_browser firefox
|
||||||
|
package_browser chromium
|
||||||
3
public/bulma.css
vendored
Normal file
3
public/bulma.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
|
|
@ -3,10 +3,19 @@
|
||||||
"name": "VibeGuard",
|
"name": "VibeGuard",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Hide toxic social-media posts locally.",
|
"description": "Hide toxic social-media posts locally.",
|
||||||
|
"icons": {
|
||||||
|
"16": "icons/logo16.png",
|
||||||
|
"32": "icons/logo32.png",
|
||||||
|
"48": "icons/logo48.png",
|
||||||
|
"64": "icons/logo64.png",
|
||||||
|
"96": "icons/logo96.png",
|
||||||
|
"128": "icons/logo128.png"
|
||||||
|
},
|
||||||
"permissions": ["storage", "tabs", "offscreen"],
|
"permissions": ["storage", "tabs", "offscreen"],
|
||||||
"host_permissions": ["https://*.reddit.com/*", "https://*.x.com/*", "https://*.twitter.com/*", "https://*.facebook.com/*"],
|
"host_permissions": ["<all_urls>"],
|
||||||
"background": { "service_worker": "background.js", "type": "module" },
|
"background": { "service_worker": "background.js", "type": "module" },
|
||||||
"content_scripts": [{ "matches": ["https://*.reddit.com/*", "https://*.x.com/*", "https://*.twitter.com/*", "https://*.facebook.com/*"], "js": ["content.js"], "run_at": "document_idle" }],
|
"content_scripts": [{ "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" }],
|
||||||
|
"action": { "default_icon": "icons/logo32.png", "default_popup": "popup.html" },
|
||||||
"options_page": "options.html",
|
"options_page": "options.html",
|
||||||
"web_accessible_resources": [{ "resources": ["inference.js", "models/*"], "matches": ["<all_urls>"] }]
|
"web_accessible_resources": [{ "resources": ["inference.js", "models/toxicity/*", "models/toxicity/onnx/*"], "matches": ["<all_urls>"] }]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,18 @@
|
||||||
"name": "VibeGuard",
|
"name": "VibeGuard",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "Hide toxic social-media posts locally.",
|
"description": "Hide toxic social-media posts locally.",
|
||||||
"permissions": ["storage", "tabs", "https://*.reddit.com/*", "https://*.x.com/*", "https://*.twitter.com/*", "https://*.facebook.com/*"],
|
"icons": {
|
||||||
|
"16": "icons/logo16.png",
|
||||||
|
"32": "icons/logo32.png",
|
||||||
|
"48": "icons/logo48.png",
|
||||||
|
"64": "icons/logo64.png",
|
||||||
|
"96": "icons/logo96.png",
|
||||||
|
"128": "icons/logo128.png"
|
||||||
|
},
|
||||||
|
"browser_action": { "default_icon": "icons/logo32.png", "default_popup": "popup.html" },
|
||||||
|
"permissions": ["storage", "tabs", "<all_urls>"],
|
||||||
"background": { "scripts": ["background.js"], "persistent": true },
|
"background": { "scripts": ["background.js"], "persistent": true },
|
||||||
"content_scripts": [{ "matches": ["https://*.reddit.com/*", "https://*.x.com/*", "https://*.twitter.com/*", "https://*.facebook.com/*"], "js": ["content.js"], "run_at": "document_idle" }],
|
"content_scripts": [{ "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" }],
|
||||||
"options_ui": { "page": "options.html", "open_in_tab": true },
|
"options_ui": { "page": "options.html", "open_in_tab": true },
|
||||||
"web_accessible_resources": ["inference.js", "models/*"]
|
"web_accessible_resources": ["inference.js", "models/toxicity/*", "models/toxicity/onnx/*"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,70 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>VibeGuard settings</title></head><body><main><h1>VibeGuard settings</h1><form id="settings">
|
<html lang="en">
|
||||||
<label for="threshold">Toxicity threshold: <output id="threshold-value">80%</output></label><input id="threshold" type="range" min="0" max="1" step=".01">
|
<head>
|
||||||
<label for="filter-mode">Filtering mode</label><select id="filter-mode"><option value="collapse">Collapse with Show button</option><option value="hide">Hide completely</option></select>
|
<meta charset="utf-8">
|
||||||
<label><input id="show-score" type="checkbox"> Show toxicity score</label>
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<fieldset><legend>Supported sites</legend><label><input id="site-reddit" type="checkbox"> Reddit</label><label><input id="site-twitter" type="checkbox"> X/Twitter</label><label><input id="site-facebook" type="checkbox"> Facebook</label></fieldset>
|
<title>VibeGuard settings</title>
|
||||||
<button type="submit">Save settings</button> <span id="status" role="status"></span></form></main><script type="module" src="/options.js"></script></body></html>
|
<link rel="stylesheet" href="/bulma.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<section class="section">
|
||||||
|
<main class="container vibeguard-options">
|
||||||
|
<div class="box">
|
||||||
|
<figure class="has-text-centered mb-4">
|
||||||
|
<img src="/icons/logo64.png" alt="VibeGuard" class="vibeguard-logo">
|
||||||
|
</figure>
|
||||||
|
<h1 class="title">VibeGuard settings</h1>
|
||||||
|
<p class="subtitle">Configure local toxicity filtering for supported sites.</p>
|
||||||
|
<form id="settings">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="threshold">Toxicity threshold: <output id="threshold-value">80%</output></label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="slider is-fullwidth" id="threshold" type="range" min="0" max="1" step=".01">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="filter-mode">Filtering mode</label>
|
||||||
|
<div class="control">
|
||||||
|
<div class="select is-fullwidth">
|
||||||
|
<select id="filter-mode">
|
||||||
|
<option value="collapse">Collapse with Show button</option>
|
||||||
|
<option value="hide">Hide completely</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label class="checkbox"><input id="show-score" type="checkbox"> Show toxicity score</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="field">
|
||||||
|
<legend class="label">Site definitions</legend>
|
||||||
|
<div class="content">
|
||||||
|
<p>VibeGuard includes <code>mastodon</code> for the official Mastodon web interface. Add a custom definition with the same ID to override it, or add its ID to <code>disabledDefinitionIds</code> to disable it.</p>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="definitions">Custom definitions (JSON)</label>
|
||||||
|
<div class="control">
|
||||||
|
<textarea class="textarea vibeguard-json" id="definitions" rows="18" spellcheck="false"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="buttons">
|
||||||
|
<button class="button" type="button" id="import-definitions">Import JSON</button>
|
||||||
|
<button class="button" type="button" id="export-definitions">Export JSON</button>
|
||||||
|
<input id="definition-file" type="file" accept="application/json" hidden>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="is-flex is-align-items-center is-flex-wrap-wrap mt-5">
|
||||||
|
<button class="button is-primary" type="submit">Save settings</button>
|
||||||
|
<span class="vibeguard-status ml-3" id="status" role="status"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</section>
|
||||||
|
<script type="module" src="/options.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
|
||||||
26
public/popup.html
Normal file
26
public/popup.html
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>VibeGuard</title>
|
||||||
|
<link rel="stylesheet" href="/bulma.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="section">
|
||||||
|
<div class="container vibeguard-popup">
|
||||||
|
<div class="has-text-centered mb-4">
|
||||||
|
<img src="/icons/logo64.png" alt="VibeGuard" class="vibeguard-logo">
|
||||||
|
<h1 class="title is-5 mt-2 mb-1">VibeGuard</h1>
|
||||||
|
</div>
|
||||||
|
<div id="status-panel" class="notification is-light" role="status" aria-live="polite">Checking this page…</div>
|
||||||
|
<p id="definition" class="help mb-4"></p>
|
||||||
|
<div class="buttons is-flex is-flex-direction-column">
|
||||||
|
<button class="button is-primary is-fullwidth" id="pause" type="button" hidden></button>
|
||||||
|
<button class="button is-fullwidth" id="settings" type="button">Open settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="/popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -2,6 +2,7 @@ import { createRuntime } from "./runtime";
|
||||||
import type { InferenceResult } from "../shared/types";
|
import type { InferenceResult } from "../shared/types";
|
||||||
|
|
||||||
const worker = new Worker(chrome.runtime.getURL("inference.js"), { type: "module" });
|
const worker = new Worker(chrome.runtime.getURL("inference.js"), { type: "module" });
|
||||||
|
const modelBaseUrl = chrome.runtime.getURL("models/toxicity/");
|
||||||
|
|
||||||
createRuntime((requests) => new Promise((resolve, reject) => {
|
createRuntime((requests) => new Promise((resolve, reject) => {
|
||||||
const listener = (event: MessageEvent<{ results?: InferenceResult[]; error?: string }>) => {
|
const listener = (event: MessageEvent<{ results?: InferenceResult[]; error?: string }>) => {
|
||||||
|
|
@ -10,5 +11,5 @@ createRuntime((requests) => new Promise((resolve, reject) => {
|
||||||
else resolve(event.data.results ?? []);
|
else resolve(event.data.results ?? []);
|
||||||
};
|
};
|
||||||
worker.addEventListener("message", listener);
|
worker.addEventListener("message", listener);
|
||||||
worker.postMessage({ requests });
|
worker.postMessage({ requests, modelBaseUrl });
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { RuntimeMessage } from "../shared/types";
|
import type { RuntimeMessage } from "../shared/types";
|
||||||
|
|
||||||
const worker = new Worker(chrome.runtime.getURL("inference.js"), { type: "module" });
|
const worker = new Worker(chrome.runtime.getURL("inference.js"), { type: "module" });
|
||||||
|
const modelBaseUrl = chrome.runtime.getURL("models/toxicity/");
|
||||||
|
|
||||||
chrome.runtime.onMessage.addListener((message: RuntimeMessage, _sender, sendResponse) => {
|
chrome.runtime.onMessage.addListener((message: RuntimeMessage, _sender, sendResponse) => {
|
||||||
if (message.type !== "OFFSCREEN_INFER") return false;
|
if (message.type !== "OFFSCREEN_INFER") return false;
|
||||||
|
|
@ -10,6 +11,6 @@ chrome.runtime.onMessage.addListener((message: RuntimeMessage, _sender, sendResp
|
||||||
else sendResponse({ results: event.data.results });
|
else sendResponse({ results: event.data.results });
|
||||||
};
|
};
|
||||||
worker.addEventListener("message", listener);
|
worker.addEventListener("message", listener);
|
||||||
worker.postMessage({ requests: message.requests });
|
worker.postMessage({ requests: message.requests, modelBaseUrl });
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { InferenceQueue } from "../inference/queue";
|
import { InferenceQueue } from "../inference/queue";
|
||||||
import { loadSettings, saveSettings } from "../shared/settings";
|
import { loadSettings, saveSettings, setDefinitionPaused } from "../shared/settings";
|
||||||
import type { InferenceRequest, InferenceResult, RuntimeMessage } from "../shared/types";
|
import type { InferenceRequest, InferenceResult, RuntimeMessage } from "../shared/types";
|
||||||
|
|
||||||
export function createRuntime(runBatch: (requests: InferenceRequest[]) => Promise<InferenceResult[]>): void {
|
export function createRuntime(runBatch: (requests: InferenceRequest[]) => Promise<InferenceResult[]>): void {
|
||||||
|
|
@ -17,6 +17,13 @@ export function createRuntime(runBatch: (requests: InferenceRequest[]) => Promis
|
||||||
}
|
}
|
||||||
if (message.type === "GET_SETTINGS") { loadSettings().then((settings) => sendResponse({ type: "SETTINGS", settings })); return true; }
|
if (message.type === "GET_SETTINGS") { loadSettings().then((settings) => sendResponse({ type: "SETTINGS", settings })); return true; }
|
||||||
if (message.type === "SET_SETTINGS") { saveSettings(message.settings).then((settings) => sendResponse({ type: "SETTINGS", settings })); return true; }
|
if (message.type === "SET_SETTINGS") { saveSettings(message.settings).then((settings) => sendResponse({ type: "SETTINGS", settings })); return true; }
|
||||||
|
if (message.type === "SET_DEFINITION_PAUSED") {
|
||||||
|
loadSettings()
|
||||||
|
.then((settings) => saveSettings(setDefinitionPaused(settings, message.definitionId, message.paused)))
|
||||||
|
.then((settings) => sendResponse({ type: "SETTINGS", settings }))
|
||||||
|
.catch((error) => sendResponse({ error: String(error) }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (message.type === "PING") { sendResponse({ type: "PONG", pending: queue.pendingCount }); }
|
if (message.type === "PING") { sendResponse({ type: "PONG", pending: queue.pendingCount }); }
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
145
src/content/definition-engine.ts
Normal file
145
src/content/definition-engine.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
import { hashText, normalizeText } from "../shared/hash";
|
||||||
|
import type { NormalizedPost, SiteDefinition } from "../shared/types";
|
||||||
|
|
||||||
|
export class DefinitionEngine {
|
||||||
|
constructor(readonly definition: SiteDefinition) {}
|
||||||
|
|
||||||
|
discover(root: Document | Element): NormalizedPost[] {
|
||||||
|
const posts: NormalizedPost[] = [];
|
||||||
|
const elements = this.findPostElements(root);
|
||||||
|
for (const element of elements) {
|
||||||
|
const text = this.extractText(element);
|
||||||
|
if (!text) continue;
|
||||||
|
const id = this.resolveId(element, text);
|
||||||
|
element.setAttribute("data-vibeguard-id", id);
|
||||||
|
posts.push({ id, text, element, site: this.definition.id });
|
||||||
|
}
|
||||||
|
return posts;
|
||||||
|
}
|
||||||
|
|
||||||
|
observe(onPosts: (posts: NormalizedPost[]) => void): () => void {
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
const candidates = new Set<Element>();
|
||||||
|
for (const mutation of mutations) {
|
||||||
|
const target = mutation.target.nodeType === Node.ELEMENT_NODE ? mutation.target as Element : mutation.target.parentElement;
|
||||||
|
if (target) this.addCandidateAncestors(target, candidates);
|
||||||
|
mutation.addedNodes.forEach((node) => {
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||||
|
const element = node as Element;
|
||||||
|
this.addCandidateAncestors(element, candidates);
|
||||||
|
this.findPostElements(element).forEach((post) => candidates.add(post));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (candidates.size > 0) onPosts(this.normalizeElements(candidates));
|
||||||
|
});
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
characterData: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["class", "style", "hidden", "aria-expanded"]
|
||||||
|
});
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {}
|
||||||
|
|
||||||
|
private findPostElements(root: Document | Element): Element[] {
|
||||||
|
const elements = new Set<Element>();
|
||||||
|
const selectors = this.definition.post.rootSelectors.join(",");
|
||||||
|
if (root instanceof Element && root.matches(selectors)) elements.add(root);
|
||||||
|
root.querySelectorAll(selectors).forEach((element) => elements.add(element));
|
||||||
|
return [...elements];
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeElements(elements: Iterable<Element>): NormalizedPost[] {
|
||||||
|
const posts: NormalizedPost[] = [];
|
||||||
|
for (const element of elements) {
|
||||||
|
const text = this.extractText(element);
|
||||||
|
if (!text) continue;
|
||||||
|
const id = this.resolveId(element, text);
|
||||||
|
element.setAttribute("data-vibeguard-id", id);
|
||||||
|
posts.push({ id, text, element, site: this.definition.id });
|
||||||
|
}
|
||||||
|
return posts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private addCandidateAncestors(element: Element, candidates: Set<Element>): void {
|
||||||
|
const selectors = this.definition.post.rootSelectors.join(",");
|
||||||
|
let current: Element | null = element;
|
||||||
|
while (current) {
|
||||||
|
if (current.matches(selectors)) candidates.add(current);
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractText(post: Element): string {
|
||||||
|
for (const selector of this.definition.post.textSelectors) {
|
||||||
|
const content = post.matches(selector) ? post : post.querySelector(selector);
|
||||||
|
if (content) return visibleText(content, this.definition.post.excludedSelectors ?? []);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveId(post: Element, text: string): string {
|
||||||
|
for (const attribute of this.definition.post.idAttributes ?? []) {
|
||||||
|
const value = post.getAttribute(attribute);
|
||||||
|
if (value) return `${this.definition.id}:${value}`;
|
||||||
|
}
|
||||||
|
for (const selector of this.definition.post.permalinkSelectors ?? []) {
|
||||||
|
const href = post.querySelector<HTMLAnchorElement>(selector)?.href;
|
||||||
|
if (href) return `${this.definition.id}:${href}`;
|
||||||
|
}
|
||||||
|
return `${this.definition.id}:${location.origin}:${hashText(text)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectDefinition(definitions: SiteDefinition[], currentUrl = location.href, root: Document = document): SiteDefinition | undefined {
|
||||||
|
return definitions.find((definition) => matchesAnyUrlPattern(definition.urlPatterns, currentUrl) && definition.requiredSelectors.every((selector) => root.querySelector(selector) !== null));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesAnyUrlPattern(patterns: string[], url: string): boolean {
|
||||||
|
return patterns.some((pattern) => matchesUrlPattern(pattern, url));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesUrlPattern(pattern: string, value: string): boolean {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (pattern === "<all_urls>") return url.protocol === "http:" || url.protocol === "https:";
|
||||||
|
const match = pattern.match(/^(\*|https?|file):\/\/(\*|\*\.[^/*]+|[^/*]+)\/(.*)$/);
|
||||||
|
if (!match) return false;
|
||||||
|
const [, scheme, host, path] = match;
|
||||||
|
if (scheme !== "*" && `${scheme}:` !== url.protocol) return false;
|
||||||
|
if (host !== "*" && !(host?.startsWith("*.") ? url.hostname === host.slice(2) || url.hostname.endsWith(`.${host.slice(2)}`) : url.hostname === host)) return false;
|
||||||
|
return wildcardMatches(path ?? "", `${url.pathname}${url.search}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function wildcardMatches(pattern: string, value: string): boolean {
|
||||||
|
const expression = `^${pattern.split("*").map(escapeRegex).join(".*")}$`;
|
||||||
|
return new RegExp(expression).test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
||||||
|
|
||||||
|
function visibleText(element: Element, excludedSelectors: string[]): string {
|
||||||
|
const chunks: string[] = [];
|
||||||
|
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||||
|
let node: Node | null;
|
||||||
|
while ((node = walker.nextNode())) {
|
||||||
|
const parent = node.parentElement;
|
||||||
|
if (!parent || excludedSelectors.some((selector) => parent.closest(selector))) continue;
|
||||||
|
if (isVisible(parent, element)) chunks.push(node.textContent ?? "");
|
||||||
|
}
|
||||||
|
return normalizeText(chunks.join(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVisible(element: Element, boundary: Element): boolean {
|
||||||
|
let current: Element | null = element;
|
||||||
|
while (current) {
|
||||||
|
if (current.hasAttribute("hidden")) return false;
|
||||||
|
const style = getComputedStyle(current);
|
||||||
|
if (style.display === "none" || style.visibility === "hidden") return false;
|
||||||
|
if (current === boundary) break;
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
22
src/content/definitions.ts
Normal file
22
src/content/definitions.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { SiteDefinition } from "../shared/types";
|
||||||
|
|
||||||
|
export const BUILTIN_DEFINITIONS: SiteDefinition[] = [{
|
||||||
|
id: "mastodon",
|
||||||
|
name: "Mastodon (official web interface)",
|
||||||
|
urlPatterns: ["<all_urls>"],
|
||||||
|
requiredSelectors: [".status__content", ".status, .detailed-status"],
|
||||||
|
post: {
|
||||||
|
rootSelectors: [".status", ".detailed-status"],
|
||||||
|
textSelectors: [".status__content", ".e-content"],
|
||||||
|
excludedSelectors: [".status__content__spoiler-link", ".status__content__read-more-button"],
|
||||||
|
idAttributes: ["data-id"],
|
||||||
|
permalinkSelectors: ["a.status__relative-time[href]", "a.detailed-status__datetime[href]", "a.u-url.u-uid[href]"]
|
||||||
|
}
|
||||||
|
}];
|
||||||
|
|
||||||
|
export function effectiveDefinitions(customDefinitions: SiteDefinition[], disabledDefinitionIds: string[]): SiteDefinition[] {
|
||||||
|
const customIds = new Set(customDefinitions.map((definition) => definition.id));
|
||||||
|
const disabled = new Set(disabledDefinitionIds);
|
||||||
|
return [...customDefinitions, ...BUILTIN_DEFINITIONS.filter((definition) => !customIds.has(definition.id))]
|
||||||
|
.filter((definition) => !disabled.has(definition.id));
|
||||||
|
}
|
||||||
|
|
@ -1,41 +1,71 @@
|
||||||
import type { InferenceResult, Settings } from "../shared/types";
|
import type { InferenceResult, Settings } from "../shared/types";
|
||||||
|
|
||||||
const HIDDEN = "data-vibeguard-hidden";
|
const HIDDEN = "data-vibeguard-hidden";
|
||||||
const ORIGINAL_DISPLAY = "data-vibeguard-display";
|
const ORIGINAL_DISPLAY = "data-vibeguard-original-display";
|
||||||
|
const PLACEHOLDER = "data-vibeguard-placeholder";
|
||||||
|
const PLACEHOLDER_FOR = "data-vibeguard-placeholder-for";
|
||||||
|
|
||||||
export function applyResult(element: Element, result: InferenceResult, settings: Settings): void {
|
export interface FilterOptions {
|
||||||
|
postId?: string;
|
||||||
|
onShow?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyResult(element: Element, result: InferenceResult, settings: Settings, options: FilterOptions = {}): void {
|
||||||
const shouldFilter = result.label === "toxic" && result.probability >= settings.threshold;
|
const shouldFilter = result.label === "toxic" && result.probability >= settings.threshold;
|
||||||
if (!shouldFilter) { restore(element); return; }
|
const postId = options.postId ?? result.id;
|
||||||
|
if (!shouldFilter) { restore(element, postId); return; }
|
||||||
|
|
||||||
element.setAttribute(HIDDEN, "true");
|
element.setAttribute(HIDDEN, "true");
|
||||||
|
const html = element as HTMLElement;
|
||||||
|
if (!html.hasAttribute(ORIGINAL_DISPLAY)) html.setAttribute(ORIGINAL_DISPLAY, html.style.display);
|
||||||
|
html.style.display = "none";
|
||||||
if (settings.filterMode === "hide") {
|
if (settings.filterMode === "hide") {
|
||||||
element.setAttribute(ORIGINAL_DISPLAY, (element as HTMLElement).style.display);
|
|
||||||
(element as HTMLElement).style.display = "none";
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = element as HTMLElement;
|
let placeholder = ownedPlaceholderAfter(element, postId);
|
||||||
if (!html.dataset.vibeguardOriginalDisplay) html.dataset.vibeguardOriginalDisplay = html.style.display;
|
if (!placeholder) {
|
||||||
html.style.display = "none";
|
|
||||||
let placeholder = element.nextElementSibling;
|
|
||||||
if (!placeholder?.matches("[data-vibeguard-placeholder]")) {
|
|
||||||
placeholder = document.createElement("div");
|
placeholder = document.createElement("div");
|
||||||
placeholder.setAttribute("data-vibeguard-placeholder", "true");
|
placeholder.setAttribute(PLACEHOLDER, "true");
|
||||||
|
placeholder.setAttribute(PLACEHOLDER_FOR, postId);
|
||||||
element.insertAdjacentElement("afterend", placeholder);
|
element.insertAdjacentElement("afterend", placeholder);
|
||||||
}
|
}
|
||||||
placeholder.className = "vibeguard-placeholder";
|
placeholder.className = "vibeguard-placeholder";
|
||||||
placeholder.textContent = `Content hidden as toxic${settings.showScore ? ` (${Math.round(result.probability * 100)}%)` : ""}`;
|
const message = `Content hidden as toxic${settings.showScore ? ` (${Math.round(result.probability * 100)}%)` : ""}`;
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.textContent = "Show";
|
button.textContent = "Show";
|
||||||
button.addEventListener("click", () => { restore(element); placeholder?.remove(); });
|
button.addEventListener("click", () => {
|
||||||
placeholder.append(" ", button);
|
if (options.onShow) options.onShow();
|
||||||
|
else restore(element, postId);
|
||||||
|
});
|
||||||
|
placeholder.replaceChildren(message, " ", button);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restore(element: Element): void {
|
export function hasAppliedFilter(element: Element, postId: string, settings: Settings): boolean {
|
||||||
const html = element as HTMLElement;
|
const html = element as HTMLElement;
|
||||||
html.style.display = html.dataset.vibeguardOriginalDisplay ?? "";
|
return element.getAttribute(HIDDEN) === "true" && html.style.display === "none"
|
||||||
delete html.dataset.vibeguardOriginalDisplay;
|
&& (settings.filterMode === "hide" || ownedPlaceholderAfter(element, postId) !== undefined);
|
||||||
element.removeAttribute(HIDDEN);
|
}
|
||||||
element.nextElementSibling?.matches("[data-vibeguard-placeholder]") && element.nextElementSibling.remove();
|
|
||||||
|
export function restore(element: Element, postId?: string): void {
|
||||||
|
const html = element as HTMLElement;
|
||||||
|
html.style.display = html.getAttribute(ORIGINAL_DISPLAY) ?? "";
|
||||||
|
html.removeAttribute(ORIGINAL_DISPLAY);
|
||||||
|
element.removeAttribute(HIDDEN);
|
||||||
|
const placeholder = element.nextElementSibling;
|
||||||
|
if (placeholder?.hasAttribute(PLACEHOLDER) && (!postId || placeholder.getAttribute(PLACEHOLDER_FOR) === postId)) placeholder.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeOrphanedPlaceholders(postId: string, elements: Iterable<Element>): void {
|
||||||
|
const currentElements = new Set(elements);
|
||||||
|
document.querySelectorAll(`[${PLACEHOLDER_FOR}]`).forEach((placeholder) => {
|
||||||
|
if (placeholder.getAttribute(PLACEHOLDER_FOR) !== postId) return;
|
||||||
|
if (!currentElements.has(placeholder.previousElementSibling as Element)) placeholder.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownedPlaceholderAfter(element: Element, postId: string): Element | undefined {
|
||||||
|
const placeholder = element.nextElementSibling;
|
||||||
|
return placeholder?.hasAttribute(PLACEHOLDER) && placeholder.getAttribute(PLACEHOLDER_FOR) === postId ? placeholder : undefined;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,148 @@
|
||||||
import { createParser, siteForLocation } from "./parsers";
|
import { effectiveDefinitions } from "./definitions";
|
||||||
import { applyResult } from "./filter";
|
import { DefinitionEngine, selectDefinition } from "./definition-engine";
|
||||||
|
import { applyResult, hasAppliedFilter, removeOrphanedPlaceholders, restore } from "./filter";
|
||||||
import { loadSettings } from "../shared/settings";
|
import { loadSettings } from "../shared/settings";
|
||||||
import { hashText } from "../shared/hash";
|
import { hashText } from "../shared/hash";
|
||||||
import { priorityFor } from "../inference/queue";
|
import { priorityFor } from "../inference/queue";
|
||||||
import type { InferenceRequest, RuntimeMessage, Settings } from "../shared/types";
|
import type { InferenceRequest, PageStatus, RuntimeMessage, Settings } from "../shared/types";
|
||||||
|
|
||||||
const site = siteForLocation();
|
void start();
|
||||||
if (site) void start(site);
|
|
||||||
|
|
||||||
async function start(activeSite: NonNullable<typeof site>): Promise<void> {
|
async function start(): Promise<void> {
|
||||||
let settings = await requestSettings();
|
const settingsPromise = requestSettings();
|
||||||
if (!settings.enabledSites[activeSite]) return;
|
let settings: Settings;
|
||||||
const parser = createParser(activeSite);
|
console.debug("[VibeGuard] Content script status listener starting", { url: location.href });
|
||||||
|
let stopCurrent = (): void => {};
|
||||||
|
let reloadSequence = 0;
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener((message: RuntimeMessage, _sender, sendResponse) => {
|
||||||
|
if (message.type !== "GET_PAGE_STATUS") return false;
|
||||||
|
console.debug("[VibeGuard] Page status requested", { url: location.href });
|
||||||
|
settingsPromise.then((initialSettings) => getPageStatus(initialSettings)).then((status) => {
|
||||||
|
console.debug("[VibeGuard] Sending page status", status);
|
||||||
|
sendResponse({ type: "PAGE_STATUS", status });
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
settings = await settingsPromise;
|
||||||
|
|
||||||
|
const restart = (): void => {
|
||||||
|
stopCurrent();
|
||||||
|
restoreAllFilters();
|
||||||
|
currentReloadSequence = ++reloadSequence;
|
||||||
|
stopCurrent = runDefinition(settings, currentReloadSequence);
|
||||||
|
};
|
||||||
|
|
||||||
|
chrome.storage.onChanged.addListener(async (changes, area) => {
|
||||||
|
if (area !== "local" || !changes["vibeguard.settings"]?.newValue) return;
|
||||||
|
settings = await loadSettings();
|
||||||
|
restart();
|
||||||
|
});
|
||||||
|
|
||||||
|
restart();
|
||||||
|
window.addEventListener("pagehide", () => stopCurrent());
|
||||||
|
}
|
||||||
|
|
||||||
|
function runDefinition(settings: Settings, sequence: number): () => void {
|
||||||
|
const definition = selectDefinition(effectiveDefinitions(settings.customDefinitions, settings.disabledDefinitionIds));
|
||||||
|
if (!definition) return () => {};
|
||||||
|
console.debug("[VibeGuard] Supported page detected", { definitionId: definition.id, definitionName: definition.name, url: location.href });
|
||||||
|
const parser = new DefinitionEngine(definition);
|
||||||
const navigationId = crypto.randomUUID();
|
const navigationId = crypto.randomUUID();
|
||||||
const seen = new WeakSet<Element>();
|
const fingerprints = new WeakMap<Element, string>();
|
||||||
|
const results = new Map<string, Parameters<typeof applyResult>[1]>();
|
||||||
|
const revealedPostIds = new Set<string>();
|
||||||
|
let stopped = false;
|
||||||
|
|
||||||
const process = (posts: ReturnType<typeof parser.discover>): void => {
|
const reconcileFilters = (): void => {
|
||||||
posts.forEach((post) => {
|
if (stopped) return;
|
||||||
if (seen.has(post.element)) return;
|
const elementsById = new Map<string, Element[]>();
|
||||||
seen.add(post.element);
|
document.querySelectorAll("[data-vibeguard-id]").forEach((element) => {
|
||||||
|
const id = element.getAttribute("data-vibeguard-id");
|
||||||
|
if (!id) return;
|
||||||
|
const elements = elementsById.get(id) ?? [];
|
||||||
|
elements.push(element);
|
||||||
|
elementsById.set(id, elements);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [id, result] of results) {
|
||||||
|
const elements = elementsById.get(id) ?? [];
|
||||||
|
const shouldFilter = result.label === "toxic" && result.probability >= settings.threshold && !revealedPostIds.has(id);
|
||||||
|
for (const element of elements) {
|
||||||
|
if (!shouldFilter) restore(element, id);
|
||||||
|
else if (!hasAppliedFilter(element, id, settings)) {
|
||||||
|
applyResult(element, result, settings, {
|
||||||
|
postId: id,
|
||||||
|
onShow: () => { revealedPostIds.add(id); reconcileFilters(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removeOrphanedPlaceholders(id, elements);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const process = (posts: ReturnType<typeof parser.discover>, source: "initial" | "mutation"): void => {
|
||||||
|
if (stopped) return;
|
||||||
|
const newPosts = posts.filter((post) => {
|
||||||
|
const fingerprint = `${post.id}:${hashText(post.text)}`;
|
||||||
|
if (fingerprints.get(post.element) === fingerprint) return false;
|
||||||
|
fingerprints.set(post.element, fingerprint);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (source === "initial" || newPosts.length > 0) {
|
||||||
|
console.debug("[VibeGuard] Posts discovered for analysis", {
|
||||||
|
definitionId: definition.id,
|
||||||
|
source,
|
||||||
|
count: newPosts.length,
|
||||||
|
posts: newPosts.map((post) => ({ id: post.id, text: post.text, textHash: hashText(post.text) }))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
newPosts.forEach((post) => {
|
||||||
const request: InferenceRequest = {
|
const request: InferenceRequest = {
|
||||||
id: post.id, text: post.text, site: activeSite, navigationId,
|
id: post.id, text: post.text, site: definition.id, navigationId,
|
||||||
priority: priorityFor(isVisible(post.element), document.visibilityState === "visible"),
|
priority: priorityFor(isVisible(post.element), document.visibilityState === "visible"),
|
||||||
requestId: crypto.randomUUID()
|
requestId: crypto.randomUUID()
|
||||||
};
|
};
|
||||||
void chrome.runtime.sendMessage<RuntimeMessage, { type: "INFERENCE_RESULT"; result: Parameters<typeof applyResult>[1] }>( { type: "INFER", request })
|
void chrome.runtime.sendMessage<RuntimeMessage, { type: "INFERENCE_RESULT"; result: Parameters<typeof applyResult>[1] }>({ type: "INFER", request })
|
||||||
.then((response) => { if (response?.result) applyResult(post.element, response.result, settings); });
|
.then((response) => {
|
||||||
|
if (stopped || sequence !== currentReloadSequence || !response?.result || !post.element.isConnected) return;
|
||||||
|
results.set(post.id, response.result);
|
||||||
|
reconcileFilters();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
reconcileFilters();
|
||||||
};
|
};
|
||||||
|
|
||||||
process(parser.discover(document));
|
process(parser.discover(document), "initial");
|
||||||
const stop = parser.observe(process);
|
const stopObserver = parser.observe((posts) => process(posts, "mutation"));
|
||||||
chrome.storage.onChanged.addListener(async (changes, area) => {
|
return () => {
|
||||||
if (area === "local" && changes["vibeguard.settings"]?.newValue) settings = await loadSettings();
|
stopped = true;
|
||||||
});
|
stopObserver();
|
||||||
window.addEventListener("pagehide", () => { stop(); parser.dispose(); });
|
parser.dispose();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentReloadSequence = 0;
|
||||||
|
|
||||||
|
async function getPageStatus(settings: Settings): Promise<PageStatus> {
|
||||||
|
const definition = selectDefinition(effectiveDefinitions(settings.customDefinitions, []));
|
||||||
|
if (!definition) {
|
||||||
|
console.debug("[VibeGuard] No matching definition for page status", { url: location.href });
|
||||||
|
return { supported: false, paused: false };
|
||||||
|
}
|
||||||
|
const status = {
|
||||||
|
supported: true,
|
||||||
|
paused: settings.disabledDefinitionIds.includes(definition.id),
|
||||||
|
definition: { id: definition.id, name: definition.name }
|
||||||
|
};
|
||||||
|
console.debug("[VibeGuard] Matched definition for page status", status);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreAllFilters(): void {
|
||||||
|
document.querySelectorAll('[data-vibeguard-hidden="true"]').forEach((element) => restore(element));
|
||||||
|
document.querySelectorAll('[data-vibeguard-placeholder="true"]').forEach((element) => element.remove());
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestSettings(): Promise<Settings> {
|
async function requestSettings(): Promise<Settings> {
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
import type { IPostParser, NormalizedPost, SupportedSite } from "../../shared/types";
|
|
||||||
|
|
||||||
export abstract class SelectorParser implements IPostParser {
|
|
||||||
abstract readonly site: SupportedSite;
|
|
||||||
protected abstract readonly selectors: string[];
|
|
||||||
|
|
||||||
discover(root: Document | Element): NormalizedPost[] {
|
|
||||||
const nodes = root.querySelectorAll(this.selectors.join(","));
|
|
||||||
return Array.from(nodes).flatMap((element, index) => {
|
|
||||||
const text = this.extractText(element);
|
|
||||||
if (!text) return [];
|
|
||||||
const id = this.getId(element, index);
|
|
||||||
element.setAttribute("data-vibeguard-id", id);
|
|
||||||
return [{ id, text, element, site: this.site }];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
observe(onPosts: (posts: NormalizedPost[]) => void): () => void {
|
|
||||||
const observer = new MutationObserver((mutations) => {
|
|
||||||
for (const mutation of mutations) {
|
|
||||||
mutation.addedNodes.forEach((node) => {
|
|
||||||
if (node.nodeType === Node.ELEMENT_NODE) onPosts(this.discover(node as Element));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
observer.observe(document.body, { childList: true, subtree: true });
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose(): void {}
|
|
||||||
|
|
||||||
protected extractText(element: Element): string {
|
|
||||||
return (element.getAttribute("data-vibeguard-text") ?? element.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private getId(element: Element, index: number): string {
|
|
||||||
const nativeId = element.getAttribute("data-testid") ?? element.getAttribute("data-id") ?? element.id;
|
|
||||||
return `${this.site}:${nativeId || `${index}-${this.extractText(element).slice(0, 32)}`}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import { SelectorParser } from "./base";
|
|
||||||
|
|
||||||
export class FacebookParser extends SelectorParser {
|
|
||||||
readonly site = "facebook" as const;
|
|
||||||
protected readonly selectors = ["div[role='article']"];
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import type { IPostParser, SupportedSite } from "../../shared/types";
|
|
||||||
import { FacebookParser } from "./facebook";
|
|
||||||
import { RedditParser } from "./reddit";
|
|
||||||
import { TwitterParser } from "./twitter";
|
|
||||||
|
|
||||||
export function siteForLocation(hostname = location.hostname): SupportedSite | undefined {
|
|
||||||
if (hostname === "reddit.com" || hostname.endsWith(".reddit.com")) return "reddit";
|
|
||||||
if (hostname === "x.com" || hostname.endsWith(".x.com") || hostname === "twitter.com" || hostname.endsWith(".twitter.com")) return "twitter";
|
|
||||||
if (hostname === "facebook.com" || hostname.endsWith(".facebook.com")) return "facebook";
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createParser(site: SupportedSite): IPostParser {
|
|
||||||
if (site === "reddit") return new RedditParser();
|
|
||||||
if (site === "twitter") return new TwitterParser();
|
|
||||||
return new FacebookParser();
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import { SelectorParser } from "./base";
|
|
||||||
|
|
||||||
export class RedditParser extends SelectorParser {
|
|
||||||
readonly site = "reddit" as const;
|
|
||||||
protected readonly selectors = ["shreddit-post", "shreddit-comment", "article[data-testid='post-container']", "div[data-testid='comment']"];
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import { SelectorParser } from "./base";
|
|
||||||
|
|
||||||
export class TwitterParser extends SelectorParser {
|
|
||||||
readonly site = "twitter" as const;
|
|
||||||
protected readonly selectors = ["article[data-testid='tweet']"];
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +1,28 @@
|
||||||
import { pipeline, type TextClassificationPipeline } from "@huggingface/transformers";
|
import { env, pipeline, type TextClassificationPipeline } from "@huggingface/transformers";
|
||||||
import { hashText } from "../shared/hash";
|
import { hashText } from "../shared/hash";
|
||||||
import type { InferenceRequest, InferenceResult } from "../shared/types";
|
import type { InferenceRequest, InferenceResult } from "../shared/types";
|
||||||
import { loadModelManifest, modelBaseUrl, type ModelManifest } from "./model-metadata";
|
import { loadModelManifest, type ModelManifest } from "./model-metadata";
|
||||||
|
|
||||||
let classifier: TextClassificationPipeline | undefined;
|
let classifier: TextClassificationPipeline | undefined;
|
||||||
let classifierPromise: Promise<TextClassificationPipeline> | undefined;
|
let classifierPromise: Promise<TextClassificationPipeline> | undefined;
|
||||||
let manifest: ModelManifest | undefined;
|
let manifest: ModelManifest | undefined;
|
||||||
|
|
||||||
export async function classify(requests: InferenceRequest[]): Promise<InferenceResult[]> {
|
// Transformers.js disables local files in browser workers by default. Our model
|
||||||
const modelManifest = manifest ??= await loadModelManifest();
|
// is packaged inside the extension, so never fall back to the network.
|
||||||
classifier ??= await loadClassifier();
|
env.allowLocalModels = true;
|
||||||
|
env.allowRemoteModels = false;
|
||||||
|
env.useBrowserCache = false;
|
||||||
|
|
||||||
|
export async function classify(requests: InferenceRequest[], modelBaseUrl: string): Promise<InferenceResult[]> {
|
||||||
|
const modelManifest = manifest ??= await loadModelManifest(modelBaseUrl);
|
||||||
|
classifier ??= await loadClassifier(modelBaseUrl);
|
||||||
const invoke = classifier as unknown as (texts: string[], options: Record<string, unknown>) => Promise<Array<Array<{ label: string; score: number }> | { label: string; score: number }>>;
|
const invoke = classifier as unknown as (texts: string[], options: Record<string, unknown>) => Promise<Array<Array<{ label: string; score: number }> | { label: string; score: number }>>;
|
||||||
const outputs = await invoke(requests.map((request) => request.text), { top_k: undefined, max_length: modelManifest.maxLength });
|
const startedAt = performance.now();
|
||||||
|
// Transformers.js defaults `top_k` to 1. Request both scores because the
|
||||||
|
// filtering threshold is based on the toxic class probability, including
|
||||||
|
// when the non-toxic class is the model's highest-confidence prediction.
|
||||||
|
const outputs = await invoke(requests.map((request) => request.text), { top_k: 2, max_length: modelManifest.maxLength });
|
||||||
|
const modelDurationMs = performance.now() - startedAt;
|
||||||
return requests.map((request, index) => {
|
return requests.map((request, index) => {
|
||||||
const output = Array.isArray(outputs[index]) ? outputs[index] : [outputs[index]];
|
const output = Array.isArray(outputs[index]) ? outputs[index] : [outputs[index]];
|
||||||
const toxic = output.find((item) => resolveOutputIndex(item?.label, modelManifest) === modelManifest.labels.toxic);
|
const toxic = output.find((item) => resolveOutputIndex(item?.label, modelManifest) === modelManifest.labels.toxic);
|
||||||
|
|
@ -24,13 +35,16 @@ export async function classify(requests: InferenceRequest[]): Promise<InferenceR
|
||||||
label: probability >= 0.5 ? "toxic" : "not_toxic",
|
label: probability >= 0.5 ? "toxic" : "not_toxic",
|
||||||
probability,
|
probability,
|
||||||
navigationId: request.navigationId,
|
navigationId: request.navigationId,
|
||||||
modelRevision: modelManifest.revision
|
modelRevision: modelManifest.revision,
|
||||||
|
modelDurationMs
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadClassifier(): Promise<TextClassificationPipeline> {
|
async function loadClassifier(modelBaseUrl: string): Promise<TextClassificationPipeline> {
|
||||||
classifierPromise ??= (pipeline as unknown as (task: string, model: string, options: { device: string }) => Promise<TextClassificationPipeline>)("text-classification", modelBaseUrl(), { device: "wasm" });
|
if (!env.backends.onnx.wasm) throw new Error("ONNX WASM backend is unavailable.");
|
||||||
|
env.backends.onnx.wasm.wasmPaths = { wasm: new URL("../../ort/ort-wasm-simd-threaded.jsep.wasm", modelBaseUrl).href };
|
||||||
|
classifierPromise ??= (pipeline as unknown as (task: string, model: string, options: { device: string; local_files_only: boolean }) => Promise<TextClassificationPipeline>)("text-classification", modelBaseUrl, { device: "wasm", local_files_only: true });
|
||||||
return classifierPromise;
|
return classifierPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,8 @@ export interface ModelManifest {
|
||||||
|
|
||||||
let manifestPromise: Promise<ModelManifest> | undefined;
|
let manifestPromise: Promise<ModelManifest> | undefined;
|
||||||
|
|
||||||
export function modelBaseUrl(): string {
|
export function loadModelManifest(modelBaseUrl: string): Promise<ModelManifest> {
|
||||||
return chrome.runtime.getURL("models/toxicity/");
|
manifestPromise ??= fetch(`${modelBaseUrl}model-manifest.json`)
|
||||||
}
|
|
||||||
|
|
||||||
export function loadModelManifest(): Promise<ModelManifest> {
|
|
||||||
manifestPromise ??= fetch(`${modelBaseUrl()}model-manifest.json`)
|
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (!response.ok) throw new Error(`VibeGuard model metadata unavailable (${response.status})`);
|
if (!response.ok) throw new Error(`VibeGuard model metadata unavailable (${response.status})`);
|
||||||
return response.json() as Promise<ModelManifest>;
|
return response.json() as Promise<ModelManifest>;
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,9 @@ export class InferenceQueue {
|
||||||
enqueue(request: InferenceRequest): Promise<InferenceResult> {
|
enqueue(request: InferenceRequest): Promise<InferenceResult> {
|
||||||
const cached = this.cache.get(request.text);
|
const cached = this.cache.get(request.text);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return Promise.resolve({ ...cached, requestId: request.requestId, id: request.id, navigationId: request.navigationId });
|
const result = { ...cached, requestId: request.requestId, id: request.id, navigationId: request.navigationId, modelDurationMs: 0 };
|
||||||
|
this.logCompletion(request, result, true);
|
||||||
|
return Promise.resolve(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.pending.length >= this.maxSize) this.dropLowestPriority();
|
if (this.pending.length >= this.maxSize) this.dropLowestPriority();
|
||||||
|
|
@ -66,11 +68,21 @@ export class InferenceQueue {
|
||||||
const byRequest = new Map(results.map((result) => [result.requestId, result]));
|
const byRequest = new Map(results.map((result) => [result.requestId, result]));
|
||||||
for (const item of batch) {
|
for (const item of batch) {
|
||||||
const result = byRequest.get(item.request.requestId);
|
const result = byRequest.get(item.request.requestId);
|
||||||
if (!result) item.reject(new Error("Classifier returned no result"));
|
if (!result) {
|
||||||
else { this.cache.set(item.request.text, result); item.resolve(result); }
|
const error = new Error("Classifier returned no result");
|
||||||
|
this.logFailure(item.request, error);
|
||||||
|
item.reject(error);
|
||||||
|
} else {
|
||||||
|
this.cache.set(item.request.text, result);
|
||||||
|
this.logCompletion(item.request, result, false);
|
||||||
|
item.resolve(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
batch.forEach((item) => item.reject(error));
|
batch.forEach((item) => {
|
||||||
|
this.logFailure(item.request, error);
|
||||||
|
item.reject(error);
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
this.running = false;
|
this.running = false;
|
||||||
if (this.pending.length > 0) this.schedule();
|
if (this.pending.length > 0) this.schedule();
|
||||||
|
|
@ -87,6 +99,26 @@ export class InferenceQueue {
|
||||||
this.pending[worstIndex]?.reject(new Error("Inference queue is full"));
|
this.pending[worstIndex]?.reject(new Error("Inference queue is full"));
|
||||||
this.pending.splice(worstIndex, 1);
|
this.pending.splice(worstIndex, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private logCompletion(request: InferenceRequest, result: InferenceResult, cacheHit: boolean): void {
|
||||||
|
console.debug("[VibeGuard] Post analysis completed", {
|
||||||
|
id: request.id,
|
||||||
|
text: request.text,
|
||||||
|
textHash: hashText(request.text),
|
||||||
|
result,
|
||||||
|
modelDurationMs: result.modelDurationMs ?? 0,
|
||||||
|
cacheHit
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private logFailure(request: InferenceRequest, error: unknown): void {
|
||||||
|
console.error("[VibeGuard] Post analysis failed", {
|
||||||
|
id: request.id,
|
||||||
|
text: request.text,
|
||||||
|
textHash: hashText(request.text),
|
||||||
|
error: String(error)
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requestKey(request: InferenceRequest): string {
|
export function requestKey(request: InferenceRequest): string {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { classify } from "./classifier";
|
import { classify } from "./classifier";
|
||||||
import type { InferenceRequest } from "../shared/types";
|
import type { InferenceRequest } from "../shared/types";
|
||||||
|
|
||||||
self.onmessage = async (event: MessageEvent<{ requests: InferenceRequest[] }>) => {
|
self.onmessage = async (event: MessageEvent<{ requests: InferenceRequest[]; modelBaseUrl: string }>) => {
|
||||||
try {
|
try {
|
||||||
const results = await classify(event.data.requests);
|
const results = await classify(event.data.requests, event.data.modelBaseUrl);
|
||||||
self.postMessage({ results });
|
self.postMessage({ results });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
self.postMessage({ error: error instanceof Error ? error.message : String(error) });
|
self.postMessage({ error: error instanceof Error ? error.message : String(error) });
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { loadSettings, saveSettings } from "../shared/settings";
|
import { loadSettings, saveDefinitionConfiguration, saveSettings } from "../shared/settings";
|
||||||
|
import { validateDefinitionConfiguration } from "../shared/site-definitions";
|
||||||
import type { FilterMode, Settings } from "../shared/types";
|
import type { FilterMode, Settings } from "../shared/types";
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
|
|
||||||
|
|
@ -8,30 +9,74 @@ const thresholdValue = document.querySelector<HTMLElement>("#threshold-value");
|
||||||
const mode = document.querySelector<HTMLSelectElement>("#filter-mode");
|
const mode = document.querySelector<HTMLSelectElement>("#filter-mode");
|
||||||
const showScore = document.querySelector<HTMLInputElement>("#show-score");
|
const showScore = document.querySelector<HTMLInputElement>("#show-score");
|
||||||
const status = document.querySelector<HTMLElement>("#status");
|
const status = document.querySelector<HTMLElement>("#status");
|
||||||
|
const definitions = document.querySelector<HTMLTextAreaElement>("#definitions");
|
||||||
|
const importButton = document.querySelector<HTMLButtonElement>("#import-definitions");
|
||||||
|
const exportButton = document.querySelector<HTMLButtonElement>("#export-definitions");
|
||||||
|
const definitionFile = document.querySelector<HTMLInputElement>("#definition-file");
|
||||||
|
|
||||||
void loadSettings().then((settings) => {
|
void loadSettings().then((settings) => {
|
||||||
threshold!.value = String(settings.threshold);
|
threshold!.value = String(settings.threshold);
|
||||||
mode!.value = settings.filterMode;
|
mode!.value = settings.filterMode;
|
||||||
showScore!.checked = settings.showScore;
|
showScore!.checked = settings.showScore;
|
||||||
for (const site of ["reddit", "twitter", "facebook"] as const) document.querySelector<HTMLInputElement>(`#site-${site}`)!.checked = settings.enabledSites[site];
|
definitions!.value = JSON.stringify(definitionConfiguration(settings), null, 2);
|
||||||
updateThresholdLabel();
|
updateThresholdLabel();
|
||||||
});
|
});
|
||||||
|
|
||||||
threshold?.addEventListener("input", updateThresholdLabel);
|
threshold?.addEventListener("input", updateThresholdLabel);
|
||||||
form?.addEventListener("submit", async (event) => {
|
form?.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const settings: Partial<Settings> = {
|
try {
|
||||||
threshold: Number(threshold?.value), filterMode: mode?.value as FilterMode, showScore: showScore?.checked,
|
const configuration = parseDefinitionConfiguration();
|
||||||
enabledSites: {
|
await saveDefinitionConfiguration(configuration);
|
||||||
reddit: document.querySelector<HTMLInputElement>("#site-reddit")!.checked,
|
await saveSettings({ threshold: Number(threshold?.value), filterMode: mode?.value as FilterMode, showScore: showScore?.checked });
|
||||||
twitter: document.querySelector<HTMLInputElement>("#site-twitter")!.checked,
|
showStatus("Saved. Reload matching pages to apply definition changes.");
|
||||||
facebook: document.querySelector<HTMLInputElement>("#site-facebook")!.checked
|
} catch (error) { showStatus(error instanceof Error ? error.message : String(error), true); }
|
||||||
}
|
});
|
||||||
};
|
|
||||||
await saveSettings(settings);
|
importButton?.addEventListener("click", () => definitionFile?.click());
|
||||||
if (status) { status.textContent = "Saved"; setTimeout(() => { status.textContent = ""; }, 1500); }
|
definitionFile?.addEventListener("change", async () => {
|
||||||
|
const file = definitionFile.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(await file.text()) as unknown;
|
||||||
|
const validation = validateDefinitionConfiguration(parsed);
|
||||||
|
if (!validation.valid) throw new Error(validation.errors.join("\n"));
|
||||||
|
if (definitions) definitions.value = JSON.stringify(validation.value, null, 2);
|
||||||
|
showStatus("Definition JSON imported. Save to apply it.");
|
||||||
|
} catch (error) { showStatus(error instanceof Error ? error.message : String(error), true); }
|
||||||
|
definitionFile.value = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
exportButton?.addEventListener("click", () => {
|
||||||
|
try {
|
||||||
|
const blob = new Blob([JSON.stringify(parseDefinitionConfiguration(), null, 2)], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = "vibeguard-definitions.json";
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) { showStatus(error instanceof Error ? error.message : String(error), true); }
|
||||||
});
|
});
|
||||||
|
|
||||||
function updateThresholdLabel(): void {
|
function updateThresholdLabel(): void {
|
||||||
if (thresholdValue && threshold) thresholdValue.textContent = `${Math.round(Number(threshold.value) * 100)}%`;
|
if (thresholdValue && threshold) thresholdValue.textContent = `${Math.round(Number(threshold.value) * 100)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function definitionConfiguration(settings?: Settings): { customDefinitions: Settings["customDefinitions"]; disabledDefinitionIds: string[] } {
|
||||||
|
return { customDefinitions: settings?.customDefinitions ?? [], disabledDefinitionIds: settings?.disabledDefinitionIds ?? [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDefinitionConfiguration(): { customDefinitions: Settings["customDefinitions"]; disabledDefinitionIds: string[] } {
|
||||||
|
let parsed: unknown;
|
||||||
|
try { parsed = JSON.parse(definitions?.value ?? "{}"); } catch { throw new Error("Definition JSON is invalid."); }
|
||||||
|
const validation = validateDefinitionConfiguration(parsed);
|
||||||
|
if (!validation.valid) throw new Error(validation.errors.join("\n"));
|
||||||
|
return validation.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showStatus(message: string, isError = false): void {
|
||||||
|
if (!status) return;
|
||||||
|
status.textContent = message;
|
||||||
|
status.style.color = isError ? "#b42318" : "";
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
:root { font: 16px system-ui, sans-serif; color: #18212f; background: #f7f8fb; }
|
.vibeguard-options { max-width: 52rem; }
|
||||||
body { max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }
|
.vibeguard-logo { max-height: 4rem; max-width: min(100%, 20rem); object-fit: contain; }
|
||||||
main { background: white; border-radius: .75rem; padding: 1.5rem; box-shadow: 0 2px 14px #18212f18; }
|
.vibeguard-json { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: .9rem; }
|
||||||
label, fieldset { display: block; margin: 1rem 0; }
|
.vibeguard-status { white-space: pre-line; }
|
||||||
fieldset { border: 0; padding: 0; }
|
|
||||||
button { padding: .5rem 1rem; border: 0; border-radius: .4rem; background: #4355db; color: white; cursor: pointer; }
|
|
||||||
.vibeguard-placeholder { padding: .75rem; margin: .25rem 0; border: 1px solid #d5d9e8; background: #f1f3f9; color: #5c6475; }
|
.vibeguard-placeholder { padding: .75rem; margin: .25rem 0; border: 1px solid #d5d9e8; background: #f1f3f9; color: #5c6475; }
|
||||||
.vibeguard-placeholder button { padding: .25rem .5rem; margin-left: .5rem; }
|
.vibeguard-placeholder button { padding: .25rem .5rem; margin-left: .5rem; }
|
||||||
|
|
|
||||||
150
src/popup/main.ts
Normal file
150
src/popup/main.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import type { PageStatus, RuntimeMessage } from "../shared/types";
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
|
const statusPanel = document.querySelector<HTMLElement>("#status-panel");
|
||||||
|
const definitionLabel = document.querySelector<HTMLElement>("#definition");
|
||||||
|
const pauseButton = document.querySelector<HTMLButtonElement>("#pause");
|
||||||
|
const settingsButton = document.querySelector<HTMLButtonElement>("#settings");
|
||||||
|
|
||||||
|
let activeTabId: number | undefined;
|
||||||
|
let currentStatus: PageStatus | undefined;
|
||||||
|
|
||||||
|
void loadStatus();
|
||||||
|
|
||||||
|
pauseButton?.addEventListener("click", async () => {
|
||||||
|
if (activeTabId === undefined || !currentStatus?.definition || !pauseButton) return;
|
||||||
|
pauseButton.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await chrome.runtime.sendMessage({
|
||||||
|
type: "SET_DEFINITION_PAUSED",
|
||||||
|
definitionId: currentStatus.definition.id,
|
||||||
|
paused: !currentStatus.paused
|
||||||
|
} satisfies RuntimeMessage);
|
||||||
|
if (response?.error) throw new Error(response.error);
|
||||||
|
await loadStatus();
|
||||||
|
} catch (error) {
|
||||||
|
showError(error instanceof Error ? error.message : String(error));
|
||||||
|
pauseButton.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
settingsButton?.addEventListener("click", () => void chrome.runtime.openOptionsPage());
|
||||||
|
|
||||||
|
async function loadStatus(): Promise<void> {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Firefox exposes the `chrome` namespace with callback-style APIs in some
|
||||||
|
// versions. Supplying a callback keeps this working there as well as in
|
||||||
|
// browsers that return a Promise when no callback is supplied.
|
||||||
|
const [tab] = await queryTabs({ active: true, currentWindow: true });
|
||||||
|
activeTabId = tab?.id;
|
||||||
|
console.debug("[VibeGuard popup] Active tab selected", { id: tab?.id, url: tab?.url, status: tab?.status });
|
||||||
|
if (activeTabId === undefined) {
|
||||||
|
console.warn("[VibeGuard popup] Active tab has no usable ID", { tab });
|
||||||
|
showUnsupported("The active tab is unavailable.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.debug("[VibeGuard popup] Requesting page status", { tabId: activeTabId });
|
||||||
|
const response = await sendTabMessage(activeTabId, { type: "GET_PAGE_STATUS" } satisfies RuntimeMessage);
|
||||||
|
console.debug("[VibeGuard popup] Page status response", { tabId: activeTabId, response });
|
||||||
|
if (!response?.status) throw new Error("No page status was returned.");
|
||||||
|
renderStatus(response.status);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[VibeGuard popup] Unable to read page status", { tabId: activeTabId, error });
|
||||||
|
showUnsupported("VibeGuard is not available on this page.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryTabs(query: chrome.tabs.QueryInfo): Promise<chrome.tabs.Tab[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
chrome.tabs.query(query, (tabs) => {
|
||||||
|
const error = chrome.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(tabs);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendTabMessage(tabId: number, message: RuntimeMessage): Promise<{ type?: string; status?: PageStatus } | undefined> {
|
||||||
|
return sendTabMessageOnce(tabId, message).catch(async (error: unknown) => {
|
||||||
|
// A tab that was already open when the extension was loaded may not have
|
||||||
|
// received the content script yet. Firefox reports that as a missing
|
||||||
|
// receiving end; inject the MV2 bundle and retry once.
|
||||||
|
if (!isMissingReceiver(error) || typeof chrome.tabs.executeScript !== "function") throw error;
|
||||||
|
await executeContentScript(tabId);
|
||||||
|
return sendTabMessageOnce(tabId, message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendTabMessageOnce(tabId: number, message: RuntimeMessage): Promise<{ type?: string; status?: PageStatus } | undefined> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
chrome.tabs.sendMessage(tabId, message, (response) => {
|
||||||
|
const error = chrome.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(response as { type?: string; status?: PageStatus } | undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeContentScript(tabId: number): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
chrome.tabs.executeScript(tabId, { file: "content.js" }, () => {
|
||||||
|
const error = chrome.runtime.lastError;
|
||||||
|
if (error) reject(new Error(error.message));
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingReceiver(error: unknown): boolean {
|
||||||
|
return error instanceof Error && error.message.includes("Receiving end does not exist");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatus(status: PageStatus): void {
|
||||||
|
console.debug("[VibeGuard popup] Rendering page status", status);
|
||||||
|
currentStatus = status;
|
||||||
|
if (!status.supported || !status.definition) {
|
||||||
|
showUnsupported("No supported site definition matches this page.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
statusPanel!.className = `notification ${status.paused ? "is-warning" : "is-success"}`;
|
||||||
|
statusPanel!.textContent = status.paused ? "Filtering is paused" : "Filtering is active";
|
||||||
|
definitionLabel!.textContent = `Site definition: ${status.definition.name}`;
|
||||||
|
if (pauseButton) {
|
||||||
|
pauseButton.hidden = false;
|
||||||
|
pauseButton.disabled = false;
|
||||||
|
pauseButton.textContent = status.paused ? "Resume on this site" : "Pause on this site";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUnsupported(message: string): void {
|
||||||
|
currentStatus = undefined;
|
||||||
|
statusPanel!.className = "notification is-light";
|
||||||
|
statusPanel!.textContent = message;
|
||||||
|
definitionLabel!.textContent = "";
|
||||||
|
if (pauseButton) {
|
||||||
|
pauseButton.hidden = true;
|
||||||
|
pauseButton.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message: string): void {
|
||||||
|
statusPanel!.className = "notification is-danger is-light";
|
||||||
|
statusPanel!.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(loading: boolean): void {
|
||||||
|
if (loading) {
|
||||||
|
statusPanel!.className = "notification is-light";
|
||||||
|
statusPanel!.textContent = "Checking this page…";
|
||||||
|
}
|
||||||
|
if (pauseButton) pauseButton.disabled = loading;
|
||||||
|
}
|
||||||
4
src/popup/style.css
Normal file
4
src/popup/style.css
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
body { min-width: 20rem; }
|
||||||
|
.vibeguard-popup { padding: 1rem; }
|
||||||
|
.vibeguard-logo { max-height: 3rem; max-width: 12rem; object-fit: contain; }
|
||||||
|
.buttons.is-flex-direction-column { gap: .5rem; }
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { DEFAULT_SETTINGS, type Settings } from "./types";
|
import { DEFAULT_SETTINGS, type Settings, type SiteDefinition } from "./types";
|
||||||
|
import { validateDefinitionConfiguration } from "./site-definitions";
|
||||||
|
|
||||||
const KEY = "vibeguard.settings";
|
const KEY = "vibeguard.settings";
|
||||||
|
|
||||||
|
|
@ -34,14 +35,32 @@ function setStorageItem(items: Record<string, unknown>): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeSettings(input?: Partial<Settings>): Settings {
|
export function mergeSettings(input?: Partial<Settings>): Settings {
|
||||||
|
const definitionConfiguration = validateDefinitionConfiguration({
|
||||||
|
customDefinitions: input?.customDefinitions ?? DEFAULT_SETTINGS.customDefinitions,
|
||||||
|
disabledDefinitionIds: input?.disabledDefinitionIds ?? DEFAULT_SETTINGS.disabledDefinitionIds
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
...DEFAULT_SETTINGS,
|
...DEFAULT_SETTINGS,
|
||||||
...input,
|
...input,
|
||||||
threshold: clamp(Number(input?.threshold ?? DEFAULT_SETTINGS.threshold), 0, 1),
|
threshold: clamp(Number(input?.threshold ?? DEFAULT_SETTINGS.threshold), 0, 1),
|
||||||
enabledSites: { ...DEFAULT_SETTINGS.enabledSites, ...input?.enabledSites }
|
customDefinitions: definitionConfiguration.valid ? definitionConfiguration.value.customDefinitions : DEFAULT_SETTINGS.customDefinitions,
|
||||||
|
disabledDefinitionIds: definitionConfiguration.valid ? definitionConfiguration.value.disabledDefinitionIds : DEFAULT_SETTINGS.disabledDefinitionIds
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function saveDefinitionConfiguration(configuration: { customDefinitions: SiteDefinition[]; disabledDefinitionIds: string[] }): Promise<Settings> {
|
||||||
|
const validation = validateDefinitionConfiguration(configuration);
|
||||||
|
if (!validation.valid) return Promise.reject(new Error(validation.errors.join("\n")));
|
||||||
|
return saveSettings(validation.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDefinitionPaused(settings: Settings, definitionId: string, paused: boolean): Settings {
|
||||||
|
const disabledDefinitionIds = new Set(settings.disabledDefinitionIds);
|
||||||
|
if (paused) disabledDefinitionIds.add(definitionId);
|
||||||
|
else disabledDefinitionIds.delete(definitionId);
|
||||||
|
return mergeSettings({ ...settings, disabledDefinitionIds: [...disabledDefinitionIds] });
|
||||||
|
}
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number): number {
|
function clamp(value: number, min: number, max: number): number {
|
||||||
return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : min;
|
return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : min;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
76
src/shared/site-definitions.ts
Normal file
76
src/shared/site-definitions.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import type { SiteDefinition } from "./types";
|
||||||
|
|
||||||
|
export interface DefinitionConfiguration {
|
||||||
|
customDefinitions: SiteDefinition[];
|
||||||
|
disabledDefinitionIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DefinitionValidation =
|
||||||
|
| { valid: true; value: DefinitionConfiguration }
|
||||||
|
| { valid: false; errors: string[] };
|
||||||
|
|
||||||
|
const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||||
|
|
||||||
|
export function validateDefinitionConfiguration(value: unknown): DefinitionValidation {
|
||||||
|
if (!isRecord(value)) return invalid("Definition configuration must be an object.");
|
||||||
|
const customDefinitions = value.customDefinitions;
|
||||||
|
const disabledDefinitionIds = value.disabledDefinitionIds;
|
||||||
|
if (!Array.isArray(customDefinitions)) return invalid("customDefinitions must be an array.");
|
||||||
|
if (!Array.isArray(disabledDefinitionIds) || disabledDefinitionIds.some((id) => typeof id !== "string" || !ID_PATTERN.test(id))) {
|
||||||
|
return invalid("disabledDefinitionIds must be an array of valid definition IDs.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
const definitions: SiteDefinition[] = [];
|
||||||
|
const ids = new Set<string>();
|
||||||
|
customDefinitions.forEach((definition, index) => {
|
||||||
|
const validation = validateSiteDefinition(definition, `customDefinitions[${index}]`);
|
||||||
|
if (!validation.valid) errors.push(...validation.errors);
|
||||||
|
else if (ids.has(validation.value.id)) errors.push(`Duplicate definition ID: ${validation.value.id}.`);
|
||||||
|
else { ids.add(validation.value.id); definitions.push(validation.value); }
|
||||||
|
});
|
||||||
|
return errors.length > 0 ? { valid: false, errors } : { valid: true, value: { customDefinitions: definitions, disabledDefinitionIds: [...new Set(disabledDefinitionIds)] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSiteDefinition(value: unknown, path = "definition"): { valid: true; value: SiteDefinition } | { valid: false; errors: string[] } {
|
||||||
|
if (!isRecord(value)) return invalid(`${path} must be an object.`);
|
||||||
|
const errors: string[] = [];
|
||||||
|
const id = value.id;
|
||||||
|
const name = value.name;
|
||||||
|
if (typeof id !== "string" || !ID_PATTERN.test(id)) errors.push(`${path}.id must use lowercase letters, digits, and hyphens.`);
|
||||||
|
if (typeof name !== "string" || !name.trim()) errors.push(`${path}.name is required.`);
|
||||||
|
const urlPatterns = stringArray(value.urlPatterns, `${path}.urlPatterns`, errors, isUrlPattern);
|
||||||
|
const requiredSelectors = stringArray(value.requiredSelectors, `${path}.requiredSelectors`, errors, isCssSelector);
|
||||||
|
const post = value.post;
|
||||||
|
if (!isRecord(post)) errors.push(`${path}.post must be an object.`);
|
||||||
|
const rootSelectors = isRecord(post) ? stringArray(post.rootSelectors, `${path}.post.rootSelectors`, errors, isCssSelector) : [];
|
||||||
|
const textSelectors = isRecord(post) ? stringArray(post.textSelectors, `${path}.post.textSelectors`, errors, isCssSelector) : [];
|
||||||
|
const excludedSelectors = isRecord(post) && post.excludedSelectors !== undefined ? stringArray(post.excludedSelectors, `${path}.post.excludedSelectors`, errors, isCssSelector, false) : undefined;
|
||||||
|
const idAttributes = isRecord(post) && post.idAttributes !== undefined ? stringArray(post.idAttributes, `${path}.post.idAttributes`, errors, (attribute) => /^[\w:-]+$/.test(attribute), false) : undefined;
|
||||||
|
const permalinkSelectors = isRecord(post) && post.permalinkSelectors !== undefined ? stringArray(post.permalinkSelectors, `${path}.post.permalinkSelectors`, errors, isCssSelector, false) : undefined;
|
||||||
|
if (errors.length > 0) return { valid: false, errors };
|
||||||
|
return { valid: true, value: { id: id as string, name: (name as string).trim(), urlPatterns, requiredSelectors, post: { rootSelectors, textSelectors, ...(excludedSelectors?.length ? { excludedSelectors } : {}), ...(idAttributes?.length ? { idAttributes } : {}), ...(permalinkSelectors?.length ? { permalinkSelectors } : {}) } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUrlPattern(pattern: string): boolean {
|
||||||
|
return pattern === "<all_urls>" || /^(?:\*|https?|file):\/\/(?:\*|\*\.[^/*]+|[^/*]+)\/.*$/.test(pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringArray(value: unknown, path: string, errors: string[], predicate: (item: string) => boolean, required = true): string[] {
|
||||||
|
if (!Array.isArray(value) || (required && value.length === 0) || value.some((item) => typeof item !== "string" || !predicate(item))) {
|
||||||
|
errors.push(`${path} must be ${required ? "a non-empty " : "an optional "}array of valid values.`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return value as string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCssSelector(selector: string): boolean {
|
||||||
|
if (typeof document === "undefined") return selector.trim().length > 0;
|
||||||
|
try { document.createElement("div").matches(selector); return true; } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalid(error: string): { valid: false; errors: string[] } { return { valid: false, errors: [error] }; }
|
||||||
|
|
@ -1,19 +1,32 @@
|
||||||
export type SupportedSite = "reddit" | "twitter" | "facebook";
|
|
||||||
export type QueuePriority = 0 | 1 | 2 | 3;
|
export type QueuePriority = 0 | 1 | 2 | 3;
|
||||||
export type FilterMode = "collapse" | "hide";
|
export type FilterMode = "collapse" | "hide";
|
||||||
|
|
||||||
|
export interface SiteDefinition {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
urlPatterns: string[];
|
||||||
|
requiredSelectors: string[];
|
||||||
|
post: {
|
||||||
|
rootSelectors: string[];
|
||||||
|
textSelectors: string[];
|
||||||
|
excludedSelectors?: string[];
|
||||||
|
idAttributes?: string[];
|
||||||
|
permalinkSelectors?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface NormalizedPost {
|
export interface NormalizedPost {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
element: Element;
|
element: Element;
|
||||||
site: SupportedSite;
|
site: string;
|
||||||
tabId?: number;
|
tabId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PostDescriptor {
|
export interface PostDescriptor {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
site: SupportedSite;
|
site: string;
|
||||||
tabId?: number;
|
tabId?: number;
|
||||||
navigationId: string;
|
navigationId: string;
|
||||||
}
|
}
|
||||||
|
|
@ -31,34 +44,40 @@ export interface InferenceResult {
|
||||||
probability: number;
|
probability: number;
|
||||||
navigationId: string;
|
navigationId: string;
|
||||||
modelRevision?: string;
|
modelRevision?: string;
|
||||||
|
/** Duration of the model invocation that produced this result, in milliseconds. */
|
||||||
|
modelDurationMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
threshold: number;
|
threshold: number;
|
||||||
filterMode: FilterMode;
|
filterMode: FilterMode;
|
||||||
showScore: boolean;
|
showScore: boolean;
|
||||||
enabledSites: Record<SupportedSite, boolean>;
|
customDefinitions: SiteDefinition[];
|
||||||
|
disabledDefinitionIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageStatus {
|
||||||
|
supported: boolean;
|
||||||
|
paused: boolean;
|
||||||
|
definition?: { id: string; name: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
threshold: 0.8,
|
threshold: 0.8,
|
||||||
filterMode: "collapse",
|
filterMode: "collapse",
|
||||||
showScore: true,
|
showScore: true,
|
||||||
enabledSites: { reddit: true, twitter: true, facebook: true }
|
customDefinitions: [],
|
||||||
|
disabledDefinitionIds: []
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IPostParser {
|
|
||||||
readonly site: SupportedSite;
|
|
||||||
discover(root: Document | Element): NormalizedPost[];
|
|
||||||
observe(onPosts: (posts: NormalizedPost[]) => void): () => void;
|
|
||||||
dispose(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RuntimeMessage =
|
export type RuntimeMessage =
|
||||||
| { type: "INFER"; request: InferenceRequest }
|
| { type: "INFER"; request: InferenceRequest }
|
||||||
| { type: "INFERENCE_RESULT"; result: InferenceResult }
|
| { type: "INFERENCE_RESULT"; result: InferenceResult }
|
||||||
| { type: "GET_SETTINGS" }
|
| { type: "GET_SETTINGS" }
|
||||||
| { type: "SET_SETTINGS"; settings: Partial<Settings> }
|
| { type: "SET_SETTINGS"; settings: Partial<Settings> }
|
||||||
| { type: "SETTINGS"; settings: Settings }
|
| { type: "SETTINGS"; settings: Settings }
|
||||||
|
| { type: "GET_PAGE_STATUS" }
|
||||||
|
| { type: "PAGE_STATUS"; status: PageStatus }
|
||||||
|
| { type: "SET_DEFINITION_PAUSED"; definitionId: string; paused: boolean }
|
||||||
| { type: "OFFSCREEN_INFER"; requests: InferenceRequest[] }
|
| { type: "OFFSCREEN_INFER"; requests: InferenceRequest[] }
|
||||||
| { type: "PING" };
|
| { type: "PING" };
|
||||||
|
|
|
||||||
62
tests/definition-engine.test.ts
Normal file
62
tests/definition-engine.test.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { DefinitionEngine, matchesUrlPattern, selectDefinition } from "../src/content/definition-engine";
|
||||||
|
import { BUILTIN_DEFINITIONS } from "../src/content/definitions";
|
||||||
|
import type { SiteDefinition } from "../src/shared/types";
|
||||||
|
|
||||||
|
const definition: SiteDefinition = {
|
||||||
|
id: "example-social",
|
||||||
|
name: "Example Social",
|
||||||
|
urlPatterns: ["https://*.example.test/*"],
|
||||||
|
requiredSelectors: [".feed"],
|
||||||
|
post: { rootSelectors: ["article.post"], textSelectors: [".content"], excludedSelectors: [".exclude"], idAttributes: ["data-id"], permalinkSelectors: ["a.permalink[href]"] }
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("DefinitionEngine", () => {
|
||||||
|
it("discovers the supplied root, extracts only visible text, and uses a stable ID", () => {
|
||||||
|
document.body.innerHTML = '<article class="post" data-id="42"><div class="content">Visible <span class="exclude">skip</span><span style="display:none">hidden</span></div></article>';
|
||||||
|
const post = new DefinitionEngine(definition).discover(document.querySelector("article")!)[0];
|
||||||
|
expect(post).toMatchObject({ id: "example-social:42", text: "visible" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses permalink IDs and selects only definitions with URL and marker matches", () => {
|
||||||
|
document.body.innerHTML = '<div class="feed"><article class="post"><div class="content">Hello</div><a class="permalink" href="/users/a/statuses/1">link</a></article></div>';
|
||||||
|
const engine = new DefinitionEngine(definition);
|
||||||
|
expect(engine.discover(document)[0]?.id).toContain("/users/a/statuses/1");
|
||||||
|
expect(selectDefinition([definition], "https://social.example.test/home")).toBe(definition);
|
||||||
|
expect(selectDefinition([definition], "https://other.test/home")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches browser-style URL patterns", () => {
|
||||||
|
expect(matchesUrlPattern("https://*.example.test/*", "https://a.example.test/path?q=1")).toBe(true);
|
||||||
|
expect(matchesUrlPattern("https://*.example.test/*", "http://a.example.test/path")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers Mastodon timeline, profile, and detailed reply status containers", () => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div class="status" data-id="timeline"><div class="status__content">Timeline post</div></div>
|
||||||
|
<div class="status" data-id="profile"><div class="status__content">Profile post</div></div>
|
||||||
|
<div class="detailed-status"><a class="detailed-status__datetime" href="/users/a/statuses/3"></a><div class="status__content">Reply in thread</div></div>`;
|
||||||
|
const posts = new DefinitionEngine(BUILTIN_DEFINITIONS[0]!).discover(document);
|
||||||
|
expect(posts.map((post) => post.id)).toEqual(["mastodon:timeline", "mastodon:profile", "mastodon:http://localhost:3000/users/a/statuses/3"]);
|
||||||
|
expect(posts.map((post) => post.text)).toEqual(["timeline post", "profile post", "reply in thread"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not include hidden Mastodon content-warning text until it is revealed", () => {
|
||||||
|
document.body.innerHTML = '<div class="status" data-id="cw"><div class="status__content"><p>Content warning <a class="status__content__spoiler-link">Show more</a></p><div id="hidden" style="display:none">Hidden post text</div></div></div>';
|
||||||
|
const engine = new DefinitionEngine(BUILTIN_DEFINITIONS[0]!);
|
||||||
|
expect(engine.discover(document)[0]?.text).toBe("content warning");
|
||||||
|
document.querySelector("#hidden")?.removeAttribute("style");
|
||||||
|
expect(engine.discover(document)[0]?.text).toBe("content warning hidden post text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rediscovers a Mastodon status when a content warning is revealed", async () => {
|
||||||
|
document.body.innerHTML = '<div class="status" data-id="cw"><div class="status__content"><p>Content warning</p><div id="hidden" style="display:none">Revealed text</div></div></div>';
|
||||||
|
const engine = new DefinitionEngine(BUILTIN_DEFINITIONS[0]!);
|
||||||
|
const observed: string[] = [];
|
||||||
|
const stop = engine.observe((posts) => observed.push(...posts.map((post) => post.text)));
|
||||||
|
document.querySelector("#hidden")?.removeAttribute("style");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
stop();
|
||||||
|
expect(observed).toContain("content warning revealed text");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { applyResult, restore } from "../src/content/filter";
|
import { applyResult, removeOrphanedPlaceholders, restore } from "../src/content/filter";
|
||||||
import { DEFAULT_SETTINGS } from "../src/shared/types";
|
import { DEFAULT_SETTINGS } from "../src/shared/types";
|
||||||
|
|
||||||
describe("content filtering", () => {
|
describe("content filtering", () => {
|
||||||
|
afterEach(() => document.body.replaceChildren());
|
||||||
|
|
||||||
it("collapses toxic content and restores it", () => {
|
it("collapses toxic content and restores it", () => {
|
||||||
const element = document.createElement("article");
|
const element = document.createElement("article");
|
||||||
document.body.append(element);
|
document.body.append(element);
|
||||||
|
|
@ -13,4 +15,27 @@ describe("content filtering", () => {
|
||||||
expect(element.style.display).toBe("");
|
expect(element.style.display).toBe("");
|
||||||
expect(document.querySelector("[data-vibeguard-placeholder]")).toBeNull();
|
expect(document.querySelector("[data-vibeguard-placeholder]")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves the original display value when filtering is reconciled repeatedly", () => {
|
||||||
|
const element = document.createElement("article");
|
||||||
|
document.body.append(element);
|
||||||
|
const result = { requestId: "1", id: "1", textHash: "x", label: "toxic" as const, probability: .91, navigationId: "n" };
|
||||||
|
applyResult(element, result, DEFAULT_SETTINGS);
|
||||||
|
applyResult(element, result, DEFAULT_SETTINGS);
|
||||||
|
restore(element);
|
||||||
|
expect(element.style.display).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes placeholders no longer paired with the current post element", () => {
|
||||||
|
const stale = document.createElement("div");
|
||||||
|
const orphan = document.createElement("div");
|
||||||
|
orphan.setAttribute("data-vibeguard-placeholder-for", "mastodon:1");
|
||||||
|
const current = document.createElement("article");
|
||||||
|
const currentPlaceholder = document.createElement("div");
|
||||||
|
currentPlaceholder.setAttribute("data-vibeguard-placeholder-for", "mastodon:1");
|
||||||
|
document.body.append(stale, orphan, current, currentPlaceholder);
|
||||||
|
removeOrphanedPlaceholders("mastodon:1", [current]);
|
||||||
|
expect(orphan.isConnected).toBe(false);
|
||||||
|
expect(currentPlaceholder.isConnected).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
20
tests/settings.test.ts
Normal file
20
tests/settings.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { setDefinitionPaused } from "../src/shared/settings";
|
||||||
|
import { DEFAULT_SETTINGS } from "../src/shared/types";
|
||||||
|
|
||||||
|
describe("definition pause settings", () => {
|
||||||
|
it("adds and removes a definition ID without disturbing other settings", () => {
|
||||||
|
const settings = setDefinitionPaused({ ...DEFAULT_SETTINGS, threshold: .65 }, "mastodon", true);
|
||||||
|
expect(settings.disabledDefinitionIds).toEqual(["mastodon"]);
|
||||||
|
expect(settings.threshold).toBe(.65);
|
||||||
|
|
||||||
|
const resumed = setDefinitionPaused(settings, "mastodon", false);
|
||||||
|
expect(resumed.disabledDefinitionIds).toEqual([]);
|
||||||
|
expect(resumed.threshold).toBe(.65);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not duplicate an already paused definition", () => {
|
||||||
|
const settings = setDefinitionPaused({ ...DEFAULT_SETTINGS, disabledDefinitionIds: ["mastodon"] }, "mastodon", true);
|
||||||
|
expect(settings.disabledDefinitionIds).toEqual(["mastodon"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
30
tests/site-definitions.test.ts
Normal file
30
tests/site-definitions.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { BUILTIN_DEFINITIONS, effectiveDefinitions } from "../src/content/definitions";
|
||||||
|
import { validateDefinitionConfiguration } from "../src/shared/site-definitions";
|
||||||
|
|
||||||
|
const customDefinition = {
|
||||||
|
id: "example-social",
|
||||||
|
name: "Example Social",
|
||||||
|
urlPatterns: ["https://*.example.test/*"],
|
||||||
|
requiredSelectors: [".feed"],
|
||||||
|
post: { rootSelectors: ["article"], textSelectors: [".content"], idAttributes: ["data-id"] }
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("site definitions", () => {
|
||||||
|
it("accepts declarative custom definitions and deduplicates disabled IDs", () => {
|
||||||
|
const result = validateDefinitionConfiguration({ customDefinitions: [customDefinition], disabledDefinitionIds: ["mastodon", "mastodon"] });
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
if (result.valid) expect(result.value.disabledDefinitionIds).toEqual(["mastodon"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects executable or malformed definition fields", () => {
|
||||||
|
const result = validateDefinitionConfiguration({ customDefinitions: [{ ...customDefinition, id: "Bad ID", post: { ...customDefinition.post, rootSelectors: ["["] } }], disabledDefinitionIds: [] });
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a custom definition replace a bundled definition by ID", () => {
|
||||||
|
const override = { ...BUILTIN_DEFINITIONS[0]!, name: "Custom Mastodon" };
|
||||||
|
const definitions = effectiveDefinitions([override], []);
|
||||||
|
expect(definitions.filter((definition) => definition.id === "mastodon")).toEqual([override]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -39,6 +39,7 @@ function inputsFor(mode: string): Record<string, string> {
|
||||||
background: mode === "firefox" ? "src/background/firefox.ts" : "src/background/chromium.ts",
|
background: mode === "firefox" ? "src/background/firefox.ts" : "src/background/chromium.ts",
|
||||||
inference: "src/inference/worker.ts",
|
inference: "src/inference/worker.ts",
|
||||||
options: "src/options/main.ts",
|
options: "src/options/main.ts",
|
||||||
|
popup: "src/popup/main.ts",
|
||||||
offscreen: "src/background/offscreen.ts"
|
offscreen: "src/background/offscreen.ts"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -49,6 +50,7 @@ function manifestPlugin(mode: string): Plugin {
|
||||||
generateBundle() {
|
generateBundle() {
|
||||||
const filename = mode === "firefox" ? "public/manifest.firefox.json" : "public/manifest.chromium.json";
|
const filename = mode === "firefox" ? "public/manifest.firefox.json" : "public/manifest.chromium.json";
|
||||||
this.emitFile({ type: "asset", fileName: "manifest.json", source: readFileSync(filename, "utf8") });
|
this.emitFile({ type: "asset", fileName: "manifest.json", source: readFileSync(filename, "utf8") });
|
||||||
|
this.emitFile({ type: "asset", fileName: "ort/ort-wasm-simd-threaded.jsep.wasm", source: readFileSync("node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.jsep.wasm") });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue