Version 2 improvements with UI and functionality
@@ -2,6 +2,34 @@
|
|||||||
|
|
||||||
All notable changes to GreasyBoii will be documented in this file.
|
All notable changes to GreasyBoii will be documented in this file.
|
||||||
|
|
||||||
|
## [2.0.0] - 2026-09-02 (Chrome and Firefox)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Visual element picker for the rule editor's Element Selector field
|
||||||
|
- Advanced rule actions: auto-click (once or repeating), find & replace text,
|
||||||
|
SPA re-apply (MutationObserver-based re-checking of a rule's match condition)
|
||||||
|
- Tools tab: bulk media downloader, link harvester (bulk-open/export), page data
|
||||||
|
scraper (CSV/JSON export)
|
||||||
|
- Rule import/export as JSON
|
||||||
|
- One-click "Dark Mode This Site" rule generator
|
||||||
|
- Master enable/disable kill-switch (`Ctrl+Shift+G`) with a toolbar badge
|
||||||
|
- New extension icon (shared design, rasterized separately per browser)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Both `manifest.json` files now request the `downloads` permission (used only by the
|
||||||
|
bulk media downloader) and declare a `commands` shortcut for the kill-switch
|
||||||
|
- `package-all.sh` no longer hardcodes a version or zips inline; it now delegates to
|
||||||
|
`package-chrome-release.sh` and `package-firefox-release.sh`, which each read their
|
||||||
|
version from their own `manifest.json`
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- Landed Firefox-first (element picker relayed through `storage.local`+`background.js`
|
||||||
|
since the popup closes before a page click, and JS rules injected via
|
||||||
|
`wrappedJSObject`/script-tag). Chrome reaches full feature parity in this same
|
||||||
|
release; its content script keeps routing JS injection through the background
|
||||||
|
service worker's `chrome.scripting.executeScript` (MAIN world) instead, since that
|
||||||
|
CSP-bypass mechanism was already Chrome's approach pre-2.0 — see CLAUDE.md.
|
||||||
|
|
||||||
## [1.0.2] - 2024-10-16
|
## [1.0.2] - 2024-10-16
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
GreasyBoii is a browser extension (a lightweight Greasemonkey/Tampermonkey-style tool) that
|
||||||
|
injects custom JavaScript and CSS into pages based on URL-substring or CSS-selector match
|
||||||
|
rules, configured through a popup UI. There is no build system, package manager, or test
|
||||||
|
suite — it's plain HTML/CSS/JS shipped straight to the browser.
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
The extension is maintained as two **parallel, independently-edited** implementations:
|
||||||
|
|
||||||
|
- `chrome/` — Manifest V3 (service worker background script)
|
||||||
|
- `firefox/` — Manifest V2 (persistent background script, `browser.*` namespace)
|
||||||
|
|
||||||
|
Each directory is a complete, self-contained extension (manifest, background script, content
|
||||||
|
script, popup HTML/CSS/JS, vendored Bootstrap, icons). **There is no shared source** — a
|
||||||
|
change to matching/rule logic in one directory must be manually ported to the other. When
|
||||||
|
editing behavior, always check whether the same fix is needed in both `chrome/*.js` and
|
||||||
|
`firefox/*.js`; diff the two files first (`diff chrome/foo.js firefox/foo.js`) to see how they
|
||||||
|
currently diverge before deciding how to apply a change to both.
|
||||||
|
|
||||||
|
Key API differences between the two versions:
|
||||||
|
- Namespace: `chrome.*` vs. the promise-based `browser.*`. Style is otherwise mixed on the
|
||||||
|
Chrome side rather than uniform: `chrome/background.js` keeps the original
|
||||||
|
callback style throughout; `chrome/content.js` and `chrome/popup.js` were ported from
|
||||||
|
the Firefox files and use the promise form instead (valid since Chrome 88+ returns a
|
||||||
|
Promise from most `chrome.*` calls when the callback is omitted — the manifest already
|
||||||
|
requires Chrome 88+). Don't "fix" this into one style without checking both still work;
|
||||||
|
it's a leftover of how the port was done, not a bug.
|
||||||
|
- Chrome's `background.js` handles JS execution via `chrome.scripting.executeScript` (world:
|
||||||
|
`MAIN`) to bypass page CSP, relayed there from `chrome/content.js`'s `injectJavaScript`;
|
||||||
|
Firefox's `content.js` instead injects a `<script>` tag directly (with a
|
||||||
|
`wrappedJSObject.eval` attempt first) since Firefox's MV2 background script can't reach
|
||||||
|
the page context the same way. This is the one piece of rule-execution logic that's
|
||||||
|
genuinely different between the two, not just a namespace swap.
|
||||||
|
- Both `background.js` files now do more than proxy `getRules`: they also relay the element
|
||||||
|
picker's result (`pickerResult`/`pickerCancelled` → `storage.local`, since the popup closes
|
||||||
|
before the page click lands) and handle the `toggle-greasyboii` keyboard command.
|
||||||
|
|
||||||
|
**Both browsers reached v2.0.0 feature parity together** (see CHANGELOG.md 2.0.0): advanced
|
||||||
|
rule actions (auto-click, text-replace, MutationObserver-based SPA re-apply — see
|
||||||
|
`executeRule`/`watchForChanges` in `content.js`), a visual element picker
|
||||||
|
(`startElementPicker`/`computeSelector` in `content.js`; the relay/restore dance lives in
|
||||||
|
`restorePendingPicker`/`collectModalSnapshot` in `popup.js`), on-demand page tools (bulk
|
||||||
|
media download, link harvesting, data scraping — the `runtime.onMessage` handlers under
|
||||||
|
"On-demand tools" in `content.js`), rule import/export, a one-click dark-mode rule generator,
|
||||||
|
and the master kill-switch. It shipped Firefox-first; `chrome/popup.js` is close to a
|
||||||
|
mechanical `browser.` → `chrome.` port of `firefox/popup.js` (see the API-differences bullets
|
||||||
|
above for what didn't port mechanically).
|
||||||
|
|
||||||
|
## Core architecture (both browsers)
|
||||||
|
|
||||||
|
1. **`content.js`** runs on every page (`document_idle`, all frames excluded). It reads
|
||||||
|
`rules` from `storage.sync`, evaluates each enabled rule's match condition against the
|
||||||
|
current page, and for matches injects the rule's JS and/or CSS.
|
||||||
|
2. **Rule matching** (`matchType`): `url` (substring match against `location.href`), `element`
|
||||||
|
(a `document.querySelector` hit), or `both` (both must hold). See the `initGreasyBoii`
|
||||||
|
function in `content.js` for the exact matching logic.
|
||||||
|
3. **`background.js`** brokers privileged operations content scripts can't do directly
|
||||||
|
(reading storage, and on Chrome, executing page-world JS to bypass CSP).
|
||||||
|
4. **`popup.js`** is the rule editor CRUD UI (Bootstrap 5 dark theme, vendored locally after a
|
||||||
|
CSP issue with the CDN version — see CHANGELOG.md 1.0.2). It reads/writes the same `rules`
|
||||||
|
array in `storage.sync`, keyed by array index (not by id), and re-renders the list via
|
||||||
|
`renderRules()`/`attachRuleEventListeners()` (event delegation) after every mutation.
|
||||||
|
|
||||||
|
### Storage format
|
||||||
|
|
||||||
|
A single `storage.sync` key `rules` holding an array of rule objects:
|
||||||
|
```js
|
||||||
|
{ name, matchType, urlPattern, elementSelector, javascript, css, enabled }
|
||||||
|
```
|
||||||
|
Rule objects (v2.0.0+, both browsers) also carry optional advanced fields:
|
||||||
|
`autoClickSelector`, `autoClickIntervalSec`, `autoClickMax`, `reapplyOnDomChanges`,
|
||||||
|
`textReplacements` (an array of `{find, replace}`). There's also a separate top-level
|
||||||
|
`storage.sync` key, `masterEnabled` (boolean, default `true`) — the global kill-switch, read
|
||||||
|
by `content.js` before evaluating any rule and toggled by `background.js`/the popup header
|
||||||
|
switch.
|
||||||
|
|
||||||
|
There is no schema migration mechanism — if you change this shape, both `content.js` files and
|
||||||
|
`popup.js` (both copies) need to keep reading/writing it consistently, and existing stored
|
||||||
|
rules from older versions won't be retrofitted.
|
||||||
|
|
||||||
|
## Manual testing (no automated tests exist)
|
||||||
|
|
||||||
|
- Load unpacked: Chrome → `chrome://extensions/` → enable Developer mode → "Load unpacked" →
|
||||||
|
select `chrome/`. Firefox → `about:debugging#/runtime/this-firefox` → "Load Temporary
|
||||||
|
Add-on" → select `firefox/manifest.json`.
|
||||||
|
- Reload after edits: Chrome extensions page reload icon; Firefox `about:debugging` → Reload.
|
||||||
|
- `test-page.html` in the repo root is a scratch page for exercising rules against.
|
||||||
|
- Debug surfaces: content script logs to the page console (F12); background script logs to
|
||||||
|
the extension's service worker console (Chrome) or the `about:debugging` Inspect view
|
||||||
|
(Firefox); popup logs via right-click → Inspect on the open popup.
|
||||||
|
|
||||||
|
## Packaging
|
||||||
|
|
||||||
|
`./package-all.sh` just calls `./package-chrome-release.sh` and `./package-firefox-release.sh`.
|
||||||
|
Each reads its own version out of its own `manifest.json`, validates the required-files
|
||||||
|
checklist from `PACKAGING.md`, and zips its folder's contents (not the folder itself) to
|
||||||
|
`dist/greasyboii-<browser>-v<version>.zip` with `manifest.json` at the zip root. Neither
|
||||||
|
script hardcodes a version — bump the relevant `manifest.json` and the next run picks it up.
|
||||||
|
See `PACKAGING.md` for store submission steps (Chrome Web Store, addons.mozilla.org) and
|
||||||
|
`BUILD.md` for the underlying per-browser packing mechanics.
|
||||||
@@ -19,6 +19,21 @@ A powerful browser extension that allows you to execute custom JavaScript and CS
|
|||||||
- 📝 **JavaScript & CSS**: Support for both JavaScript execution and CSS injection
|
- 📝 **JavaScript & CSS**: Support for both JavaScript execution and CSS injection
|
||||||
- 🔍 **Visual Indicators**: Clear badges showing rule type (URL/Element/Both) and code types (JS/CSS)
|
- 🔍 **Visual Indicators**: Clear badges showing rule type (URL/Element/Both) and code types (JS/CSS)
|
||||||
|
|
||||||
|
### 2.0 additions
|
||||||
|
|
||||||
|
- 🎯 **Visual element picker** — click "Pick on page" and select an element instead of guessing a CSS selector
|
||||||
|
- 🤖 **Auto-click** — automatically click a selector once, or repeatedly (cookie banners, "load more" buttons)
|
||||||
|
- 🔤 **Find & replace text** — swap text on the page as part of a rule
|
||||||
|
- 🔁 **SPA re-apply** — optionally re-checks a rule's condition as the page's DOM changes, for client-routed apps
|
||||||
|
- 📥 **Bulk media downloader** — scan a page for images/PDFs/videos (or custom extensions) and download them all at once
|
||||||
|
- 🔗 **Link harvester** — pull every link out of a container (or the whole page), then open selected links in new tabs or export them as a `.txt` list
|
||||||
|
- 🧮 **Page data scraper** — pull text/href/src/any attribute from every element matching a selector, export as CSV or JSON
|
||||||
|
- 💾 **Rule import/export** — back up or share your rules as a JSON file
|
||||||
|
- 🌓 **One-click dark mode** — adds a dark-mode rule for the current site in a single click
|
||||||
|
- 🔌 **Master kill-switch** — `Ctrl+Shift+G` (`Cmd+Shift+G` on Mac) instantly disables/re-enables every rule, with a toolbar badge showing the state
|
||||||
|
|
||||||
|
Available in both browsers as of v2.0.0.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Chrome Installation
|
### Chrome Installation
|
||||||
@@ -76,6 +91,42 @@ A powerful browser extension that allows you to execute custom JavaScript and CS
|
|||||||
|
|
||||||
Use the toggle switch on each rule card to quickly enable or disable rules without editing them.
|
Use the toggle switch on each rule card to quickly enable or disable rules without editing them.
|
||||||
|
|
||||||
|
### Advanced Rule Fields
|
||||||
|
|
||||||
|
Open the "Advanced" section in the rule editor for three extra actions a rule can take,
|
||||||
|
on top of JS/CSS:
|
||||||
|
|
||||||
|
- **Auto-Click Selector** — clicked once when the rule matches; set "Repeat every (sec)"
|
||||||
|
to keep clicking on an interval (e.g. re-dismissing a recurring banner), and "Max
|
||||||
|
clicks" to cap it.
|
||||||
|
- **Text Replacements** — a list of Find/Replace pairs applied to the page's visible text.
|
||||||
|
- **Re-check on page changes (for SPAs)** — re-evaluates the rule's match condition as the
|
||||||
|
DOM changes, so it still fires after a client-side route change instead of only once
|
||||||
|
on page load.
|
||||||
|
|
||||||
|
Next to the Element Selector field, click **🎯 Pick** to select an element visually
|
||||||
|
instead of writing a CSS selector by hand — hover to highlight, click to select, Esc to
|
||||||
|
cancel. Because the popup closes as soon as you click the page, the picked selector is
|
||||||
|
filled in the next time you open the popup (it re-opens the rule you were editing
|
||||||
|
automatically).
|
||||||
|
|
||||||
|
### Power Tools
|
||||||
|
|
||||||
|
The **Tools** tab has three one-off utilities that act on the current page directly,
|
||||||
|
outside the rule system:
|
||||||
|
|
||||||
|
- **Bulk Media Downloader** — pick a type (Images/PDFs/Videos/custom extensions), scan
|
||||||
|
the page, then download everything found in one click.
|
||||||
|
- **Link Harvester** — optionally scope to a container CSS selector (e.g. `.article-list`
|
||||||
|
or `#results`), extract every link inside it, then open the selected ones in new
|
||||||
|
background tabs or export the list as `.txt`.
|
||||||
|
- **Page Data Scraper** — pull text, `href`, `src`, or any attribute from every element
|
||||||
|
matching a selector, then export the results as CSV or JSON.
|
||||||
|
|
||||||
|
The header also has **⬇ Export** / **⬆ Import** for backing up or sharing your whole
|
||||||
|
rule set as JSON, and **🌓** to add a ready-made dark-mode rule for the site you're
|
||||||
|
currently on.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
### Example 1: Hide Ads on Specific Sites
|
### Example 1: Hide Ads on Specific Sites
|
||||||
@@ -202,12 +253,22 @@ Rules are stored as JSON in browser sync storage:
|
|||||||
"elementSelector": "",
|
"elementSelector": "",
|
||||||
"javascript": "console.log('Hello');",
|
"javascript": "console.log('Hello');",
|
||||||
"css": "body { background: red; }",
|
"css": "body { background: red; }",
|
||||||
"enabled": true
|
"enabled": true,
|
||||||
|
|
||||||
|
// v2.0+ advanced fields (all optional):
|
||||||
|
"autoClickSelector": "",
|
||||||
|
"autoClickIntervalSec": 0,
|
||||||
|
"autoClickMax": 0,
|
||||||
|
"reapplyOnDomChanges": false,
|
||||||
|
"textReplacements": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Both browsers also store a top-level `masterEnabled` boolean (default `true`) — the
|
||||||
|
`Ctrl+Shift+G` kill-switch — separately from the `rules` array.
|
||||||
|
|
||||||
### Browser Compatibility
|
### Browser Compatibility
|
||||||
|
|
||||||
- **Chrome**: Requires Chrome 88+ (Manifest V3)
|
- **Chrome**: Requires Chrome 88+ (Manifest V3)
|
||||||
@@ -217,28 +278,34 @@ Rules are stored as JSON in browser sync storage:
|
|||||||
|
|
||||||
## Building for Distribution
|
## Building for Distribution
|
||||||
|
|
||||||
### Chrome
|
Run `./package-all.sh` from the repo root to build both. It delegates to two dedicated
|
||||||
|
scripts, each of which reads its version straight out of its own `manifest.json` (no
|
||||||
|
hardcoded version strings to keep in sync), checks all required files are present, and
|
||||||
|
zips the folder's *contents* — not the folder itself — to the zip root:
|
||||||
|
|
||||||
1. Ensure all files are in the `chrome` directory
|
```bash
|
||||||
2. Go to `chrome://extensions/`
|
./package-all.sh
|
||||||
3. Click "Pack extension"
|
# or individually:
|
||||||
4. Select the `chrome` directory
|
./package-chrome-release.sh # -> dist/greasyboii-chrome-v<version>.zip
|
||||||
5. This creates a `.crx` file for distribution
|
./package-firefox-release.sh # -> dist/greasyboii-firefox-v<version>.zip
|
||||||
|
```
|
||||||
|
|
||||||
### Firefox
|
- **Chrome**: upload `dist/greasyboii-chrome-v<version>.zip` to the
|
||||||
|
[Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/developer/dashboard).
|
||||||
1. Install `web-ext` tool: `npm install -g web-ext`
|
(`chrome://extensions/` → "Pack extension" also still works if you want a `.crx` +
|
||||||
2. Navigate to the `firefox` directory
|
`.pem` instead.)
|
||||||
3. Run: `web-ext build`
|
- **Firefox**: upload `dist/greasyboii-firefox-v<version>.zip` to
|
||||||
4. This creates a `.zip` file in `web-ext-artifacts/`
|
[addons.mozilla.org](https://addons.mozilla.org), or sign it yourself:
|
||||||
5. Submit to [addons.mozilla.org](https://addons.mozilla.org) for signing
|
```bash
|
||||||
|
cd dist && web-ext sign --api-key=YOUR_API_KEY --api-secret=YOUR_API_SECRET --source-dir=../firefox
|
||||||
|
```
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
- **User Responsibility**: Be cautious with JavaScript code execution
|
- **User Responsibility**: Be cautious with JavaScript code execution
|
||||||
- **HTTPS**: Script injection works on both HTTP and HTTPS sites
|
- **HTTPS**: Script injection works on both HTTP and HTTPS sites
|
||||||
- **CSP**: Some sites with strict Content Security Policy may block injected scripts
|
- **CSP**: Some sites with strict Content Security Policy may block injected scripts
|
||||||
- **Permissions**: Extension requires broad permissions (`<all_urls>`) to work on all sites
|
- **Permissions**: Extension requires broad permissions (`<all_urls>`) to work on all sites, plus `downloads` (used only by the Tools tab's bulk media downloader)
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
@@ -301,6 +368,22 @@ For issues, questions, or feature requests, please contact Andy Dixon.
|
|||||||
|
|
||||||
## Version History
|
## Version History
|
||||||
|
|
||||||
|
### 2.0.0 (Chrome and Firefox)
|
||||||
|
- Visual element picker for the Element Selector field
|
||||||
|
- Auto-click rule action (once or repeating)
|
||||||
|
- Find & replace text rule action
|
||||||
|
- SPA re-apply (MutationObserver-based rule re-checking)
|
||||||
|
- Bulk media downloader (Tools tab)
|
||||||
|
- Link harvester with bulk open-in-tabs / export (Tools tab)
|
||||||
|
- Page data scraper with CSV/JSON export (Tools tab)
|
||||||
|
- Rule import/export as JSON
|
||||||
|
- One-click "Dark Mode This Site" rule generator
|
||||||
|
- Master enable/disable kill-switch with `Ctrl+Shift+G` shortcut and toolbar badge
|
||||||
|
- New icon
|
||||||
|
- Shipped Firefox-only first; Chrome caught up to full feature parity in the same 2.0.0
|
||||||
|
line (Chrome's JS injection still goes through its `chrome.scripting.executeScript`
|
||||||
|
relay rather than Firefox's `wrappedJSObject`/script-tag approach — see CLAUDE.md)
|
||||||
|
|
||||||
### 1.0.0 (Initial Release)
|
### 1.0.0 (Initial Release)
|
||||||
- URL pattern matching
|
- URL pattern matching
|
||||||
- Element selector matching
|
- Element selector matching
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Background service worker for Chrome extension
|
// Background service worker for Chrome extension
|
||||||
chrome.runtime.onInstalled.addListener(() => {
|
chrome.runtime.onInstalled.addListener(() => {
|
||||||
console.log('GreasyBoii extension installed');
|
console.log('GreasyBoii extension installed');
|
||||||
|
refreshBadge();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for messages from content script
|
// Listen for messages from content script
|
||||||
@@ -11,7 +12,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
|||||||
});
|
});
|
||||||
return true; // Keep channel open for async response
|
return true; // Keep channel open for async response
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute JavaScript via scripting API to bypass CSP
|
// Execute JavaScript via scripting API to bypass CSP
|
||||||
if (request.action === 'executeScript') {
|
if (request.action === 'executeScript') {
|
||||||
if (!sender.tab || !sender.tab.id) {
|
if (!sender.tab || !sender.tab.id) {
|
||||||
@@ -48,4 +49,40 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
|||||||
|
|
||||||
return true; // Keep channel open for async response
|
return true; // Keep channel open for async response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The popup closes the instant the user clicks the page to pick an element, so the
|
||||||
|
// content script can't hand the selector straight back to it — relay it through
|
||||||
|
// storage instead; the popup picks it up next time it opens (see popup.js).
|
||||||
|
if (request.action === 'pickerResult') {
|
||||||
|
chrome.storage.local.set({ pendingPickResult: request.selector });
|
||||||
|
} else if (request.action === 'pickerCancelled') {
|
||||||
|
chrome.storage.local.remove(['pickerPending', 'pendingPickResult']);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Master enable/disable keyboard shortcut (see manifest.json "commands")
|
||||||
|
chrome.commands.onCommand.addListener((command) => {
|
||||||
|
if (command === 'toggle-greasyboii') {
|
||||||
|
chrome.storage.sync.get(['masterEnabled'], (result) => {
|
||||||
|
const enabled = result.masterEnabled !== false;
|
||||||
|
chrome.storage.sync.set({ masterEnabled: !enabled });
|
||||||
|
// badge refresh happens via the storage.onChanged listener below
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep the toolbar badge in sync whenever masterEnabled changes, from any source
|
||||||
|
// (the shortcut above, the popup's toggle switch, or a rules import)
|
||||||
|
chrome.storage.onChanged.addListener((changes, area) => {
|
||||||
|
if (area === 'sync' && changes.masterEnabled) {
|
||||||
|
refreshBadge();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function refreshBadge() {
|
||||||
|
chrome.storage.sync.get(['masterEnabled'], (result) => {
|
||||||
|
const enabled = result.masterEnabled !== false;
|
||||||
|
chrome.action.setBadgeText({ text: enabled ? '' : 'OFF' });
|
||||||
|
chrome.action.setBadgeBackgroundColor({ color: '#dc3545' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,93 +1,348 @@
|
|||||||
// Content script that runs on all pages
|
// Content script that runs on all pages
|
||||||
(function() {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
console.log('[GreasyBoii] Content script loaded on:', window.location.href);
|
console.log('[GreasyBoii] Content script loaded on:', window.location.href);
|
||||||
|
|
||||||
// Wait for DOM to be ready
|
const ruleMatchState = new Map(); // rule name -> last known match state (edge-triggers re-apply)
|
||||||
|
const autoClickTimers = new Map(); // rule name -> setInterval id
|
||||||
|
const injectedStyles = new Map(); // rule name -> <style> element currently on the page
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', initGreasyBoii);
|
document.addEventListener('DOMContentLoaded', boot);
|
||||||
} else {
|
} else {
|
||||||
initGreasyBoii();
|
boot();
|
||||||
|
}
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
chrome.storage.sync.get(['masterEnabled']).then((result) => {
|
||||||
|
if (result.masterEnabled === false) {
|
||||||
|
console.log('[GreasyBoii] Disabled via master toggle, skipping.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initGreasyBoii();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function initGreasyBoii() {
|
function initGreasyBoii() {
|
||||||
console.log('[GreasyBoii] Initializing...');
|
console.log('[GreasyBoii] Initializing...');
|
||||||
|
|
||||||
// Get rules from storage and execute matching ones
|
chrome.storage.sync.get(['rules']).then((result) => {
|
||||||
chrome.storage.sync.get(['rules'], (result) => {
|
|
||||||
const rules = result.rules || [];
|
const rules = result.rules || [];
|
||||||
const currentUrl = window.location.href;
|
|
||||||
|
|
||||||
console.log('[GreasyBoii] Found', rules.length, 'rule(s) in storage');
|
console.log('[GreasyBoii] Found', rules.length, 'rule(s) in storage');
|
||||||
|
|
||||||
rules.forEach((rule) => {
|
rules.forEach(evaluateRule);
|
||||||
if (!rule.enabled) {
|
|
||||||
console.log('[GreasyBoii] Skipping disabled rule:', rule.name);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let shouldExecute = false;
|
if (rules.some((r) => r.enabled && r.reapplyOnDomChanges)) {
|
||||||
|
watchForChanges(rules.filter((r) => r.enabled && r.reapplyOnDomChanges));
|
||||||
// Check conditions
|
}
|
||||||
if (rule.matchType === 'url') {
|
|
||||||
shouldExecute = currentUrl.includes(rule.urlPattern);
|
|
||||||
console.log(`[GreasyBoii] URL match test for "${rule.name}": ${currentUrl} includes "${rule.urlPattern}" = ${shouldExecute}`);
|
|
||||||
} else if (rule.matchType === 'element') {
|
|
||||||
shouldExecute = document.querySelector(rule.elementSelector) !== null;
|
|
||||||
console.log(`[GreasyBoii] Element match test for "${rule.name}": selector "${rule.elementSelector}" found = ${shouldExecute}`);
|
|
||||||
} else if (rule.matchType === 'both') {
|
|
||||||
const urlMatches = currentUrl.includes(rule.urlPattern);
|
|
||||||
const elementExists = document.querySelector(rule.elementSelector) !== null;
|
|
||||||
shouldExecute = urlMatches && elementExists;
|
|
||||||
console.log(`[GreasyBoii] Combined match test for "${rule.name}": URL=${urlMatches}, Element=${elementExists}, Result=${shouldExecute}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldExecute) {
|
|
||||||
console.log(`[GreasyBoii] ✓ Executing rule: ${rule.name}`);
|
|
||||||
executeRule(rule);
|
|
||||||
} else {
|
|
||||||
console.log(`[GreasyBoii] ✗ Rule "${rule.name}" conditions not met`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function executeRule(rule) {
|
function ruleMatches(rule) {
|
||||||
// Execute JavaScript using chrome.scripting API to bypass CSP
|
const currentUrl = window.location.href;
|
||||||
if (rule.javascript && rule.javascript.trim()) {
|
if (rule.matchType === 'url') {
|
||||||
console.log(`[GreasyBoii] Sending JavaScript execution request for rule: ${rule.name}`);
|
return currentUrl.includes(rule.urlPattern);
|
||||||
|
} else if (rule.matchType === 'element') {
|
||||||
// Send message to background script to execute JavaScript
|
return !!document.querySelector(rule.elementSelector);
|
||||||
chrome.runtime.sendMessage({
|
} else if (rule.matchType === 'both') {
|
||||||
action: 'executeScript',
|
return currentUrl.includes(rule.urlPattern) && !!document.querySelector(rule.elementSelector);
|
||||||
code: rule.javascript,
|
|
||||||
ruleName: rule.name
|
|
||||||
}, (response) => {
|
|
||||||
if (chrome.runtime.lastError) {
|
|
||||||
console.error(`[GreasyBoii] Error communicating with background:`, chrome.runtime.lastError);
|
|
||||||
} else if (response && response.success) {
|
|
||||||
console.log(`[GreasyBoii] ✓ JavaScript executed successfully via background script`);
|
|
||||||
} else {
|
|
||||||
console.error(`[GreasyBoii] JavaScript execution failed:`, response?.error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Execute CSS - CSS injection doesn't have CSP issues
|
function evaluateRule(rule) {
|
||||||
if (rule.css && rule.css.trim()) {
|
if (!rule.enabled) return;
|
||||||
try {
|
|
||||||
console.log(`[GreasyBoii] Injecting CSS for rule: ${rule.name}`);
|
const matched = ruleMatches(rule);
|
||||||
|
const wasMatched = ruleMatchState.get(rule.name) === true;
|
||||||
const style = document.createElement('style');
|
ruleMatchState.set(rule.name, matched);
|
||||||
style.textContent = rule.css;
|
|
||||||
style.setAttribute('data-greasyboii-rule', rule.name);
|
if (matched && !wasMatched) {
|
||||||
(document.head || document.documentElement).appendChild(style);
|
console.log(`[GreasyBoii] ✓ Executing rule: ${rule.name}`);
|
||||||
|
executeRule(rule);
|
||||||
console.log(`[GreasyBoii] ✓ CSS injected successfully`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[GreasyBoii] Error applying CSS for rule "${rule.name}":`, error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ponytail: fixed 400ms debounce, not adaptive to mutation volume — fine for the
|
||||||
|
// "route just changed in an SPA" case this exists for; a hot mutation stream would
|
||||||
|
// want per-rule throttling instead.
|
||||||
|
function watchForChanges(watchedRules) {
|
||||||
|
let debounceTimer = null;
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(() => watchedRules.forEach(evaluateRule), 400);
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeRule(rule) {
|
||||||
|
if (rule.javascript && rule.javascript.trim()) {
|
||||||
|
injectJavaScript(rule);
|
||||||
|
}
|
||||||
|
if (rule.css && rule.css.trim()) {
|
||||||
|
injectCss(rule);
|
||||||
|
}
|
||||||
|
if (rule.autoClickSelector && rule.autoClickSelector.trim()) {
|
||||||
|
runAutoClick(rule);
|
||||||
|
}
|
||||||
|
if (rule.textReplacements && rule.textReplacements.length) {
|
||||||
|
runTextReplacements(rule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectJavaScript(rule) {
|
||||||
|
console.log(`[GreasyBoii] Sending JavaScript execution request for rule: ${rule.name}`);
|
||||||
|
|
||||||
|
// Execute via the background service worker's chrome.scripting API, which runs in
|
||||||
|
// the page's MAIN world and bypasses CSP — a content script's own eval can't do that.
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
action: 'executeScript',
|
||||||
|
code: rule.javascript,
|
||||||
|
ruleName: rule.name
|
||||||
|
}, (response) => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
console.error('[GreasyBoii] Error communicating with background:', chrome.runtime.lastError);
|
||||||
|
} else if (response && response.success) {
|
||||||
|
console.log('[GreasyBoii] ✓ JavaScript executed successfully via background script');
|
||||||
|
} else {
|
||||||
|
console.error('[GreasyBoii] JavaScript execution failed:', response && response.error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectCss(rule) {
|
||||||
|
try {
|
||||||
|
const old = injectedStyles.get(rule.name);
|
||||||
|
if (old) old.remove();
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = rule.css;
|
||||||
|
style.setAttribute('data-greasyboii-rule', rule.name);
|
||||||
|
(document.head || document.documentElement).appendChild(style);
|
||||||
|
injectedStyles.set(rule.name, style);
|
||||||
|
|
||||||
|
console.log(`[GreasyBoii] ✓ CSS injected for rule: ${rule.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[GreasyBoii] Error applying CSS for rule "${rule.name}":`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAutoClick(rule) {
|
||||||
|
const key = rule.name;
|
||||||
|
if (autoClickTimers.has(key)) return; // already running for this rule instance
|
||||||
|
|
||||||
|
const intervalSec = Number(rule.autoClickIntervalSec) || 0;
|
||||||
|
const max = Number(rule.autoClickMax) || 0;
|
||||||
|
let clicks = 0;
|
||||||
|
let timerId = null;
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (timerId) clearInterval(timerId);
|
||||||
|
autoClickTimers.delete(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clickOnce = () => {
|
||||||
|
const el = document.querySelector(rule.autoClickSelector);
|
||||||
|
if (el) {
|
||||||
|
el.click();
|
||||||
|
clicks++;
|
||||||
|
console.log(`[GreasyBoii] Auto-clicked "${rule.autoClickSelector}" for rule "${rule.name}" (${clicks})`);
|
||||||
|
}
|
||||||
|
if (max > 0 && clicks >= max) stop();
|
||||||
|
};
|
||||||
|
|
||||||
|
clickOnce();
|
||||||
|
if (intervalSec > 0 && (max === 0 || clicks < max)) {
|
||||||
|
timerId = setInterval(clickOnce, intervalSec * 1000);
|
||||||
|
autoClickTimers.set(key, timerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTextReplacements(rule) {
|
||||||
|
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
|
||||||
|
acceptNode(node) {
|
||||||
|
const parentTag = node.parentElement && node.parentElement.tagName;
|
||||||
|
return parentTag === 'SCRIPT' || parentTag === 'STYLE'
|
||||||
|
? NodeFilter.FILTER_REJECT
|
||||||
|
: NodeFilter.FILTER_ACCEPT;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodes = [];
|
||||||
|
let node;
|
||||||
|
while ((node = walker.nextNode())) nodes.push(node);
|
||||||
|
|
||||||
|
nodes.forEach((textNode) => {
|
||||||
|
let text = textNode.nodeValue;
|
||||||
|
let changed = false;
|
||||||
|
rule.textReplacements.forEach(({ find, replace }) => {
|
||||||
|
if (find && text.includes(find)) {
|
||||||
|
text = text.split(find).join(replace || '');
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (changed) textNode.nodeValue = text;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[GreasyBoii] Applied text replacements for rule: ${rule.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// On-demand tools (popup "Tools" tab talks to whichever tab is active)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||||
|
if (request.action === 'startPicker') {
|
||||||
|
startElementPicker();
|
||||||
|
} else if (request.action === 'scanMedia') {
|
||||||
|
sendResponse({ items: scanMedia(request.mediaType, request.extensions) });
|
||||||
|
} else if (request.action === 'harvestLinks') {
|
||||||
|
sendResponse({ items: harvestLinks(request.containerSelector) });
|
||||||
|
} else if (request.action === 'scrapeData') {
|
||||||
|
sendResponse({ items: scrapeData(request.selector, request.attribute) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function scanMedia(mediaType, customExtensions) {
|
||||||
|
const extMap = {
|
||||||
|
images: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'avif'],
|
||||||
|
pdfs: ['pdf'],
|
||||||
|
videos: ['mp4', 'webm', 'mov', 'avi', 'mkv']
|
||||||
|
};
|
||||||
|
const extensions = mediaType === 'custom'
|
||||||
|
? (customExtensions || '').split(',').map((e) => e.trim().toLowerCase()).filter(Boolean)
|
||||||
|
: (extMap[mediaType] || []);
|
||||||
|
|
||||||
|
const urls = new Set();
|
||||||
|
|
||||||
|
if (mediaType === 'images') {
|
||||||
|
document.querySelectorAll('img[src]').forEach((img) => urls.add(img.src));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('a[href]').forEach((a) => {
|
||||||
|
const ext = (a.href.split('?')[0].split('.').pop() || '').toLowerCase();
|
||||||
|
if (extensions.includes(ext)) urls.add(a.href);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...urls].map((url) => ({ url, filename: url.split('/').pop().split('?')[0] || 'file' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function harvestLinks(containerSelector) {
|
||||||
|
const root = (containerSelector && containerSelector.trim())
|
||||||
|
? document.querySelector(containerSelector)
|
||||||
|
: document;
|
||||||
|
if (!root) return [];
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
const links = [];
|
||||||
|
root.querySelectorAll('a[href]').forEach((a) => {
|
||||||
|
if (!seen.has(a.href)) {
|
||||||
|
seen.add(a.href);
|
||||||
|
links.push({ url: a.href, text: (a.textContent || '').trim().slice(0, 80) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrapeData(selector, attribute) {
|
||||||
|
if (!selector) return [];
|
||||||
|
return [...document.querySelectorAll(selector)].map((el) => {
|
||||||
|
if (attribute === 'text') return el.textContent.trim();
|
||||||
|
if (attribute === 'href') return el.href || '';
|
||||||
|
if (attribute === 'src') return el.src || '';
|
||||||
|
return el.getAttribute(attribute) || '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Visual element picker
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
let pickerHighlightEl = null;
|
||||||
|
let pickerBanner = null;
|
||||||
|
|
||||||
|
function startElementPicker() {
|
||||||
|
if (pickerBanner) return; // already active
|
||||||
|
|
||||||
|
pickerBanner = document.createElement('div');
|
||||||
|
pickerBanner.textContent = 'GreasyBoii: click an element to select it — Esc to cancel';
|
||||||
|
pickerBanner.style.cssText =
|
||||||
|
'position:fixed;top:0;left:0;right:0;z-index:2147483647;background:#ff8a1e;' +
|
||||||
|
'color:#111;font:600 13px/1.4 sans-serif;text-align:center;padding:8px;pointer-events:none;';
|
||||||
|
document.documentElement.appendChild(pickerBanner);
|
||||||
|
|
||||||
|
document.addEventListener('mouseover', onPickerHover, true);
|
||||||
|
document.addEventListener('click', onPickerClick, true);
|
||||||
|
document.addEventListener('keydown', onPickerKeydown, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopElementPicker() {
|
||||||
|
if (pickerHighlightEl) {
|
||||||
|
pickerHighlightEl.style.outline = '';
|
||||||
|
pickerHighlightEl = null;
|
||||||
|
}
|
||||||
|
if (pickerBanner) {
|
||||||
|
pickerBanner.remove();
|
||||||
|
pickerBanner = null;
|
||||||
|
}
|
||||||
|
document.removeEventListener('mouseover', onPickerHover, true);
|
||||||
|
document.removeEventListener('click', onPickerClick, true);
|
||||||
|
document.removeEventListener('keydown', onPickerKeydown, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickerHover(e) {
|
||||||
|
if (pickerHighlightEl) pickerHighlightEl.style.outline = '';
|
||||||
|
pickerHighlightEl = e.target;
|
||||||
|
pickerHighlightEl.style.outline = '2px solid #ff8a1e';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickerClick(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const selector = computeSelector(e.target);
|
||||||
|
stopElementPicker();
|
||||||
|
chrome.runtime.sendMessage({ action: 'pickerResult', selector });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickerKeydown(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
stopElementPicker();
|
||||||
|
chrome.runtime.sendMessage({ action: 'pickerCancelled' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: heuristic selector — prefers a unique #id, else walks up to 4 ancestors
|
||||||
|
// combining tag + first non-numeric class + :nth-of-type until the candidate is
|
||||||
|
// unique on the page. Not guaranteed minimal or unique on pathological DOMs (e.g.
|
||||||
|
// frameworks that only emit hashed/numeric class names); good enough for "point at
|
||||||
|
// a thing, get a usable selector" — upgrade to a proper CSS-selector library if that
|
||||||
|
// ever becomes a real problem.
|
||||||
|
function computeSelector(el) {
|
||||||
|
if (el.id) {
|
||||||
|
const idSelector = `#${CSS.escape(el.id)}`;
|
||||||
|
if (document.querySelectorAll(idSelector).length === 1) return idSelector;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
let node = el;
|
||||||
|
for (let depth = 0; node && node.nodeType === 1 && depth < 4; depth++) {
|
||||||
|
let part = node.tagName.toLowerCase();
|
||||||
|
const cls = [...node.classList].find((c) => c && !/\d/.test(c));
|
||||||
|
if (cls) {
|
||||||
|
part += `.${CSS.escape(cls)}`;
|
||||||
|
} else if (node.parentElement) {
|
||||||
|
const siblings = [...node.parentElement.children].filter((c) => c.tagName === node.tagName);
|
||||||
|
if (siblings.length > 1) {
|
||||||
|
part += `:nth-of-type(${siblings.indexOf(node) + 1})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.unshift(part);
|
||||||
|
|
||||||
|
const candidate = parts.join(' > ');
|
||||||
|
if (document.querySelectorAll(candidate).length === 1) return candidate;
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
return parts.join(' > ');
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2b2d31"/>
|
||||||
|
<stop offset="1" stop-color="#111214"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="bolt" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#ffcf5c"/>
|
||||||
|
<stop offset="1" stop-color="#ff8a1e"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="4" y="4" width="120" height="120" rx="28" fill="url(#bg)"/>
|
||||||
|
<rect x="4" y="4" width="120" height="120" rx="28" fill="none" stroke="#000000" stroke-opacity="0.35" stroke-width="2"/>
|
||||||
|
<polygon points="68,24 28,72 64,72 60,104 100,56 64,56 68,24" fill="url(#bolt)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 711 B |
|
Before Width: | Height: | Size: 499 B After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 115 B After Width: | Height: | Size: 527 B |
|
Before Width: | Height: | Size: 202 B After Width: | Height: | Size: 1.4 KiB |
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "GreasyBoii",
|
"name": "GreasyBoii",
|
||||||
"version": "1.0.4",
|
"version": "2.0.0",
|
||||||
"description": "Execute custom JavaScript and CSS based on URL patterns and page elements",
|
"description": "Execute custom JavaScript and CSS based on URL patterns and page elements, plus one-click page tools: bulk downloads, link harvesting, scraping and more",
|
||||||
"author": "Andy Dixon",
|
"author": "Andy Dixon",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"storage",
|
"storage",
|
||||||
"activeTab",
|
"activeTab",
|
||||||
"scripting"
|
"scripting",
|
||||||
|
"downloads"
|
||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"<all_urls>"
|
"<all_urls>"
|
||||||
@@ -15,6 +16,15 @@
|
|||||||
"background": {
|
"background": {
|
||||||
"service_worker": "background.js"
|
"service_worker": "background.js"
|
||||||
},
|
},
|
||||||
|
"commands": {
|
||||||
|
"toggle-greasyboii": {
|
||||||
|
"suggested_key": {
|
||||||
|
"default": "Ctrl+Shift+G",
|
||||||
|
"mac": "Command+Shift+G"
|
||||||
|
},
|
||||||
|
"description": "Enable/disable all GreasyBoii rules"
|
||||||
|
}
|
||||||
|
},
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
"matches": ["<all_urls>"],
|
"matches": ["<all_urls>"],
|
||||||
|
|||||||
@@ -1,25 +1,153 @@
|
|||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* GreasyBoii — grease-monkey garage theme, layered on Bootstrap dark */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--gm-bg: #16140f;
|
||||||
|
--gm-panel: #232019;
|
||||||
|
--gm-panel-alt: #1b1810;
|
||||||
|
--gm-panel-border: #4a3f2c;
|
||||||
|
--gm-amber: #ffb020;
|
||||||
|
--gm-amber-soft: #ffcf5c;
|
||||||
|
--gm-amber-deep: #ff8a1e;
|
||||||
|
--gm-text: #f1e9da;
|
||||||
|
--gm-text-muted: #b9ac93;
|
||||||
|
--gm-success: #6fae4a;
|
||||||
|
--gm-danger: #d9534f;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
width: 600px;
|
width: 600px;
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
max-height: 600px;
|
max-height: 600px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at top left, rgba(255, 138, 30, 0.08), transparent 55%),
|
||||||
|
radial-gradient(ellipse at bottom right, rgba(0, 0, 0, 0.5), transparent 60%),
|
||||||
|
var(--gm-bg);
|
||||||
|
color: var(--gm-text);
|
||||||
|
scrollbar-color: var(--gm-amber-deep) var(--gm-bg);
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rules-list {
|
::-webkit-scrollbar {
|
||||||
max-height: 450px;
|
width: 8px;
|
||||||
overflow-y: auto;
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--gm-bg);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--gm-panel-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--gm-amber-deep);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-card {
|
/* ---------------- Brand header ---------------- */
|
||||||
border: 1px solid #495057;
|
|
||||||
border-radius: 0.375rem;
|
.brand-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-family: 'Arial Narrow', 'Helvetica Neue Condensed', Arial, sans-serif;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: linear-gradient(180deg, var(--gm-amber-soft), var(--gm-amber-deep));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-tagline {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--gm-text-muted);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hazard-strip {
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--gm-amber) 0 10px,
|
||||||
|
#171512 10px 20px
|
||||||
|
);
|
||||||
|
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Tabs ---------------- */
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
border-bottom-color: var(--gm-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs .nav-link {
|
||||||
|
color: var(--gm-text-muted);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs .nav-link:hover {
|
||||||
|
color: var(--gm-amber);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs .nav-link.active {
|
||||||
|
color: var(--gm-amber);
|
||||||
|
background: transparent;
|
||||||
|
border-bottom-color: var(--gm-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Panels: rule cards + tool cards ---------------- */
|
||||||
|
|
||||||
|
.rule-card,
|
||||||
|
.tool-card {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid var(--gm-panel-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
background-color: #212529;
|
background: linear-gradient(180deg, var(--gm-panel), var(--gm-panel-alt));
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 2px 6px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card::before,
|
||||||
|
.rule-card::after,
|
||||||
|
.tool-card::before,
|
||||||
|
.tool-card::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 7px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 35% 35%, #7a7166, #2a2620);
|
||||||
|
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
.rule-card::before,
|
||||||
|
.tool-card::before {
|
||||||
|
left: 7px;
|
||||||
|
}
|
||||||
|
.rule-card::after,
|
||||||
|
.tool-card::after {
|
||||||
|
right: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-card:hover {
|
.rule-card:hover {
|
||||||
border-color: #6c757d;
|
border-color: var(--gm-amber-deep);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-header {
|
.rule-header {
|
||||||
@@ -30,8 +158,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rule-name {
|
.rule-name {
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
font-size: 1.1rem;
|
font-size: 1.05rem;
|
||||||
|
color: var(--gm-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-actions {
|
.rule-actions {
|
||||||
@@ -41,25 +170,152 @@ body {
|
|||||||
|
|
||||||
.rule-details {
|
.rule-details {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #adb5bd;
|
color: var(--gm-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-badge {
|
.rule-badge {
|
||||||
font-size: 0.75rem;
|
font-size: 0.7rem;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- Buttons ---------------- */
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(180deg, var(--gm-amber-soft), var(--gm-amber-deep));
|
||||||
|
border-color: #c9670f;
|
||||||
|
color: #201705;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4), 0 2px 4px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
.btn-primary:hover,
|
||||||
|
.btn-primary:focus {
|
||||||
|
background: linear-gradient(180deg, #ffdb7a, #ff9a3a);
|
||||||
|
border-color: #c9670f;
|
||||||
|
color: #201705;
|
||||||
|
}
|
||||||
|
.btn-primary:active {
|
||||||
|
background: linear-gradient(180deg, var(--gm-amber-deep), #e8730a);
|
||||||
|
color: #201705;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-light {
|
||||||
|
border-color: var(--gm-panel-border);
|
||||||
|
color: var(--gm-text);
|
||||||
|
}
|
||||||
|
.btn-outline-light:hover,
|
||||||
|
.btn-outline-light:focus {
|
||||||
|
background: var(--gm-amber);
|
||||||
|
border-color: var(--gm-amber);
|
||||||
|
color: #201705;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Forms ---------------- */
|
||||||
|
|
||||||
.font-monospace {
|
.font-monospace {
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-control,
|
||||||
|
.form-select {
|
||||||
|
background-color: var(--gm-panel-alt);
|
||||||
|
border-color: var(--gm-panel-border);
|
||||||
|
color: var(--gm-text);
|
||||||
|
}
|
||||||
|
.form-control:focus,
|
||||||
|
.form-select:focus {
|
||||||
|
background-color: var(--gm-panel-alt);
|
||||||
|
border-color: var(--gm-amber-deep);
|
||||||
|
color: var(--gm-text);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(255, 138, 30, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
.form-switch .form-check-input {
|
.form-switch .form-check-input {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 3rem;
|
width: 3rem;
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
|
background-color: #3a2e1c;
|
||||||
|
border-color: var(--gm-panel-border);
|
||||||
|
}
|
||||||
|
.form-switch .form-check-input:checked {
|
||||||
|
background-color: var(--gm-success);
|
||||||
|
border-color: var(--gm-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.power-switch #masterToggleLabel {
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--gm-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Modal ---------------- */
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--gm-panel);
|
||||||
|
border: 1px solid var(--gm-panel-border);
|
||||||
|
}
|
||||||
|
.modal-header,
|
||||||
|
.modal-footer {
|
||||||
|
border-color: var(--gm-panel-border);
|
||||||
|
}
|
||||||
|
#modalTitle {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--gm-amber);
|
||||||
|
}
|
||||||
.modal-lg {
|
.modal-lg {
|
||||||
max-width: 700px;
|
max-width: 700px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- Tools tab ---------------- */
|
||||||
|
|
||||||
|
.tool-card h6 {
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--gm-amber);
|
||||||
|
border-bottom: 1px solid var(--gm-panel-border);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-results {
|
||||||
|
min-height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-scroll {
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Advanced rule fields ---------------- */
|
||||||
|
|
||||||
|
#advancedSection > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
color: var(--gm-amber);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#advancedSection .border {
|
||||||
|
border-color: var(--gm-panel-border) !important;
|
||||||
|
background: var(--gm-panel-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.replacement-row .form-control {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-list {
|
||||||
|
max-height: 450px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,17 +9,101 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container-fluid p-3">
|
<div class="container-fluid p-3">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="brand-row d-flex justify-content-between align-items-center mb-2">
|
||||||
<h4 class="mb-0">GreasyBoii</h4>
|
<div class="d-flex align-items-center gap-2">
|
||||||
<button class="btn btn-primary btn-sm" id="addRuleBtn">+ Add Rule</button>
|
<img src="icon48.png" alt="" class="brand-icon">
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0 brand-title">GreasyBoii</h4>
|
||||||
|
<div class="brand-tagline">🐒 grease-monkey grade page tweaks</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch mb-0 power-switch" title="Master enable/disable (Ctrl+Shift+G)">
|
||||||
|
<input class="form-check-input" type="checkbox" id="masterToggle" checked>
|
||||||
|
<label class="form-check-label small" for="masterToggle" id="masterToggleLabel">On</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="rulesList" class="rules-list mb-3">
|
<div class="hazard-strip mb-3"></div>
|
||||||
<!-- Rules will be dynamically inserted here -->
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||||
|
<ul class="nav nav-tabs" id="mainTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="rulesTabBtn" data-bs-toggle="tab" data-bs-target="#rulesPane" type="button" role="tab">Rules</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="toolsTabBtn" data-bs-toggle="tab" data-bs-target="#toolsPane" type="button" role="tab">Tools</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-outline-light" id="darkModeBtn" title="Add a dark-mode rule for this site">🌓</button>
|
||||||
|
<button class="btn btn-sm btn-outline-light" id="exportRulesBtn" title="Export rules to JSON">⬇ Export</button>
|
||||||
|
<button class="btn btn-sm btn-outline-light" id="importRulesBtn" title="Import rules from JSON">⬆ Import</button>
|
||||||
|
<input type="file" id="importFileInput" accept="application/json" hidden>
|
||||||
|
<button class="btn btn-primary btn-sm" id="addRuleBtn">+ Add Rule</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="emptyState" class="text-center text-muted py-4" style="display: none;">
|
<div class="tab-content">
|
||||||
<p>No rules yet. Click "Add Rule" to get started.</p>
|
<div class="tab-pane fade show active" id="rulesPane" role="tabpanel">
|
||||||
|
<div id="rulesList" class="rules-list mb-3">
|
||||||
|
<!-- Rules will be dynamically inserted here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="emptyState" class="text-center text-muted py-4" style="display: none;">
|
||||||
|
<p>No rules yet. Click "Add Rule" to get started.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-pane fade" id="toolsPane" role="tabpanel">
|
||||||
|
<div class="tool-card mb-3">
|
||||||
|
<h6>📥 Bulk Media Downloader</h6>
|
||||||
|
<div class="d-flex gap-2 mb-2">
|
||||||
|
<select class="form-select form-select-sm" id="mediaType" style="max-width: 150px;">
|
||||||
|
<option value="images">Images</option>
|
||||||
|
<option value="pdfs">PDFs</option>
|
||||||
|
<option value="videos">Videos</option>
|
||||||
|
<option value="custom">Custom extensions</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="mediaExtensions" placeholder="jpg,png,zip" style="display: none;">
|
||||||
|
<button class="btn btn-sm btn-outline-light" id="scanMediaBtn">Scan Page</button>
|
||||||
|
</div>
|
||||||
|
<div id="mediaResults" class="tool-results small text-muted">No scan yet.</div>
|
||||||
|
<button class="btn btn-sm btn-primary mt-2" id="downloadMediaBtn" disabled>Download All (0)</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-card mb-3">
|
||||||
|
<h6>🔗 Link Harvester</h6>
|
||||||
|
<div class="d-flex gap-2 mb-2">
|
||||||
|
<input type="text" class="form-control form-control-sm" id="linkContainerSelector" placeholder="Container selector (blank = whole page)">
|
||||||
|
<button class="btn btn-sm btn-outline-light" id="harvestLinksBtn">Extract</button>
|
||||||
|
</div>
|
||||||
|
<div id="linkResults" class="tool-results small text-muted">No links extracted yet.</div>
|
||||||
|
<div class="d-flex gap-2 mt-2">
|
||||||
|
<button class="btn btn-sm btn-primary" id="openLinksBtn" disabled>Open Selected in Tabs</button>
|
||||||
|
<button class="btn btn-sm btn-outline-light" id="exportLinksBtn" disabled>Export .txt</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-card mb-3">
|
||||||
|
<h6>🧮 Page Data Scraper</h6>
|
||||||
|
<div class="d-flex gap-2 mb-2">
|
||||||
|
<input type="text" class="form-control form-control-sm" id="scrapeSelector" placeholder="CSS selector, e.g. .price">
|
||||||
|
<select class="form-select form-select-sm" id="scrapeAttribute" style="max-width: 120px;">
|
||||||
|
<option value="text">Text</option>
|
||||||
|
<option value="href">href</option>
|
||||||
|
<option value="src">src</option>
|
||||||
|
<option value="custom">Attribute…</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<input type="text" class="form-control form-control-sm mb-2" id="scrapeCustomAttr" placeholder="attribute name" style="display: none;">
|
||||||
|
<button class="btn btn-sm btn-outline-light" id="scrapeBtn">Scrape</button>
|
||||||
|
<div id="scrapeResults" class="tool-results small text-muted mt-2">No data scraped yet.</div>
|
||||||
|
<div class="d-flex gap-2 mt-2">
|
||||||
|
<button class="btn btn-sm btn-primary" id="exportCsvBtn" disabled>Export CSV</button>
|
||||||
|
<button class="btn btn-sm btn-outline-light" id="exportJsonBtn" disabled>Export JSON</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -55,8 +139,11 @@
|
|||||||
|
|
||||||
<div class="mb-3" id="elementSelectorGroup" style="display: none;">
|
<div class="mb-3" id="elementSelectorGroup" style="display: none;">
|
||||||
<label for="elementSelector" class="form-label">Element Selector</label>
|
<label for="elementSelector" class="form-label">Element Selector</label>
|
||||||
<input type="text" class="form-control" id="elementSelector" placeholder="e.g., #my-element or .my-class">
|
<div class="input-group">
|
||||||
<small class="form-text text-muted">Enter a CSS selector</small>
|
<input type="text" class="form-control" id="elementSelector" placeholder="e.g., #my-element or .my-class">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" id="pickElementBtn" title="Pick element on the page">🎯 Pick</button>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">Enter a CSS selector, or click Pick and select it on the page</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -69,6 +156,35 @@
|
|||||||
<textarea class="form-control font-monospace" id="css" rows="6" placeholder="body { background-color: #000; }"></textarea>
|
<textarea class="form-control font-monospace" id="css" rows="6" placeholder="body { background-color: #000; }"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<details class="mb-3" id="advancedSection">
|
||||||
|
<summary class="form-label">🔧 Advanced (auto-click, text replace, SPA re-apply)</summary>
|
||||||
|
<div class="border rounded p-2 mt-2">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="autoClickSelector" class="form-label small">Auto-Click Selector</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="autoClickSelector" placeholder="e.g., .cookie-banner .accept">
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 mb-3">
|
||||||
|
<div class="flex-fill">
|
||||||
|
<label for="autoClickInterval" class="form-label small">Repeat every (sec, 0 = once)</label>
|
||||||
|
<input type="number" min="0" class="form-control form-control-sm" id="autoClickInterval" value="0">
|
||||||
|
</div>
|
||||||
|
<div class="flex-fill">
|
||||||
|
<label for="autoClickMax" class="form-label small">Max clicks (0 = unlimited)</label>
|
||||||
|
<input type="number" min="0" class="form-control form-control-sm" id="autoClickMax" value="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="form-label small">Text Replacements</label>
|
||||||
|
<div id="textReplacementsList" class="mb-2"></div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-light mb-3" id="addReplacementBtn">+ Add Replacement</button>
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="reapplyOnDomChanges">
|
||||||
|
<label class="form-check-label" for="reapplyOnDomChanges">Re-check on page changes (for SPAs)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" id="ruleEnabled" checked>
|
<input class="form-check-input" type="checkbox" id="ruleEnabled" checked>
|
||||||
<label class="form-check-label" for="ruleEnabled">
|
<label class="form-check-label" for="ruleEnabled">
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
// Popup script for managing rules
|
// Popup script for managing rules (Chrome version)
|
||||||
let currentRules = [];
|
let currentRules = [];
|
||||||
let editingRuleIndex = -1;
|
let editingRuleIndex = -1;
|
||||||
let ruleModal;
|
let ruleModal;
|
||||||
|
let scannedMedia = [];
|
||||||
|
let harvestedLinks = [];
|
||||||
|
let scrapedData = [];
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Wait for Bootstrap to be fully loaded
|
|
||||||
if (typeof bootstrap !== 'undefined') {
|
if (typeof bootstrap !== 'undefined') {
|
||||||
initializeExtension();
|
initializeExtension();
|
||||||
} else {
|
} else {
|
||||||
// Fallback: wait a bit for Bootstrap to load
|
|
||||||
setTimeout(initializeExtension, 100);
|
setTimeout(initializeExtension, 100);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -20,21 +21,22 @@ function initializeExtension() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error initializing modal:', e);
|
console.error('Error initializing modal:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadRules();
|
loadRules();
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
|
setupAdvancedRuleFields();
|
||||||
|
setupHeaderControls();
|
||||||
|
setupToolsTab();
|
||||||
|
restorePendingPicker();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupEventListeners() {
|
function setupEventListeners() {
|
||||||
const addRuleBtn = document.getElementById('addRuleBtn');
|
const addRuleBtn = document.getElementById('addRuleBtn');
|
||||||
const saveRuleBtn = document.getElementById('saveRuleBtn');
|
const saveRuleBtn = document.getElementById('saveRuleBtn');
|
||||||
const matchTypeSelect = document.getElementById('matchType');
|
const matchTypeSelect = document.getElementById('matchType');
|
||||||
|
|
||||||
if (addRuleBtn) {
|
if (addRuleBtn) {
|
||||||
addRuleBtn.addEventListener('click', () => {
|
addRuleBtn.addEventListener('click', () => openRuleModal());
|
||||||
console.log('Add Rule button clicked');
|
|
||||||
openRuleModal();
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
console.error('Add Rule button not found!');
|
console.error('Add Rule button not found!');
|
||||||
}
|
}
|
||||||
@@ -79,7 +81,7 @@ function updateFieldVisibility(matchType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadRules() {
|
function loadRules() {
|
||||||
chrome.storage.sync.get(['rules'], (result) => {
|
chrome.storage.sync.get(['rules']).then((result) => {
|
||||||
currentRules = result.rules || [];
|
currentRules = result.rules || [];
|
||||||
renderRules();
|
renderRules();
|
||||||
});
|
});
|
||||||
@@ -96,7 +98,7 @@ function renderRules() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
emptyState.style.display = 'none';
|
emptyState.style.display = 'none';
|
||||||
|
|
||||||
rulesList.innerHTML = currentRules.map((rule, index) => {
|
rulesList.innerHTML = currentRules.map((rule, index) => {
|
||||||
const matchTypeLabel = {
|
const matchTypeLabel = {
|
||||||
'url': 'URL',
|
'url': 'URL',
|
||||||
@@ -106,19 +108,24 @@ function renderRules() {
|
|||||||
|
|
||||||
const hasJS = rule.javascript && rule.javascript.trim();
|
const hasJS = rule.javascript && rule.javascript.trim();
|
||||||
const hasCSS = rule.css && rule.css.trim();
|
const hasCSS = rule.css && rule.css.trim();
|
||||||
|
const hasAutoClick = rule.autoClickSelector && rule.autoClickSelector.trim();
|
||||||
|
const hasReplacements = rule.textReplacements && rule.textReplacements.length;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="rule-card" data-rule-index="${index}">
|
<div class="rule-card" data-rule-index="${index}">
|
||||||
<div class="rule-header">
|
<div class="rule-header">
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
<span class="rule-name">${escapeHtml(rule.name)}</span>
|
<span class="rule-name">${escapeHtml(rule.name)}</span>
|
||||||
<span class="badge rule-badge bg-secondary">${matchTypeLabel}</span>
|
<span class="badge rule-badge bg-secondary">${matchTypeLabel}</span>
|
||||||
${hasJS ? '<span class="badge rule-badge bg-warning">JS</span>' : ''}
|
${hasJS ? '<span class="badge rule-badge bg-warning">JS</span>' : ''}
|
||||||
${hasCSS ? '<span class="badge rule-badge bg-info">CSS</span>' : ''}
|
${hasCSS ? '<span class="badge rule-badge bg-info">CSS</span>' : ''}
|
||||||
|
${hasAutoClick ? '<span class="badge rule-badge bg-success">Click</span>' : ''}
|
||||||
|
${hasReplacements ? '<span class="badge rule-badge bg-primary">Replace</span>' : ''}
|
||||||
|
${rule.reapplyOnDomChanges ? '<span class="badge rule-badge bg-dark border">SPA</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="rule-actions">
|
<div class="rule-actions">
|
||||||
<div class="form-check form-switch mb-0">
|
<div class="form-check form-switch mb-0">
|
||||||
<input class="form-check-input rule-toggle" type="checkbox" ${rule.enabled ? 'checked' : ''}
|
<input class="form-check-input rule-toggle" type="checkbox" ${rule.enabled ? 'checked' : ''}
|
||||||
data-index="${index}" title="Enable/Disable">
|
data-index="${index}" title="Enable/Disable">
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm btn-outline-light btn-edit-rule" data-index="${index}" title="Edit">
|
<button class="btn btn-sm btn-outline-light btn-edit-rule" data-index="${index}" title="Edit">
|
||||||
@@ -141,19 +148,16 @@ function renderRules() {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// Add event listeners using event delegation
|
|
||||||
attachRuleEventListeners();
|
attachRuleEventListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachRuleEventListeners() {
|
function attachRuleEventListeners() {
|
||||||
const rulesList = document.getElementById('rulesList');
|
const rulesList = document.getElementById('rulesList');
|
||||||
|
|
||||||
// Remove old listeners if any
|
|
||||||
rulesList.removeEventListener('click', handleRuleClick);
|
rulesList.removeEventListener('click', handleRuleClick);
|
||||||
rulesList.removeEventListener('change', handleRuleToggle);
|
rulesList.removeEventListener('change', handleRuleToggle);
|
||||||
|
|
||||||
// Add new listeners
|
|
||||||
rulesList.addEventListener('click', handleRuleClick);
|
rulesList.addEventListener('click', handleRuleClick);
|
||||||
rulesList.addEventListener('change', handleRuleToggle);
|
rulesList.addEventListener('change', handleRuleToggle);
|
||||||
}
|
}
|
||||||
@@ -161,20 +165,17 @@ function attachRuleEventListeners() {
|
|||||||
function handleRuleClick(e) {
|
function handleRuleClick(e) {
|
||||||
const editBtn = e.target.closest('.btn-edit-rule');
|
const editBtn = e.target.closest('.btn-edit-rule');
|
||||||
const deleteBtn = e.target.closest('.btn-delete-rule');
|
const deleteBtn = e.target.closest('.btn-delete-rule');
|
||||||
|
|
||||||
if (editBtn) {
|
if (editBtn) {
|
||||||
const index = parseInt(editBtn.dataset.index);
|
editRule(parseInt(editBtn.dataset.index));
|
||||||
editRule(index);
|
|
||||||
} else if (deleteBtn) {
|
} else if (deleteBtn) {
|
||||||
const index = parseInt(deleteBtn.dataset.index);
|
deleteRule(parseInt(deleteBtn.dataset.index));
|
||||||
deleteRule(index);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRuleToggle(e) {
|
function handleRuleToggle(e) {
|
||||||
if (e.target.classList.contains('rule-toggle')) {
|
if (e.target.classList.contains('rule-toggle')) {
|
||||||
const index = parseInt(e.target.dataset.index);
|
toggleRule(parseInt(e.target.dataset.index));
|
||||||
toggleRule(index);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +185,8 @@ function openRuleModal(ruleIndex = -1) {
|
|||||||
const form = document.getElementById('ruleForm');
|
const form = document.getElementById('ruleForm');
|
||||||
|
|
||||||
form.reset();
|
form.reset();
|
||||||
|
renderReplacementRows([]);
|
||||||
|
document.getElementById('advancedSection').open = false;
|
||||||
|
|
||||||
if (ruleIndex >= 0) {
|
if (ruleIndex >= 0) {
|
||||||
modalTitle.textContent = 'Edit Rule';
|
modalTitle.textContent = 'Edit Rule';
|
||||||
@@ -194,19 +197,29 @@ function openRuleModal(ruleIndex = -1) {
|
|||||||
document.getElementById('elementSelector').value = rule.elementSelector || '';
|
document.getElementById('elementSelector').value = rule.elementSelector || '';
|
||||||
document.getElementById('javascript').value = rule.javascript || '';
|
document.getElementById('javascript').value = rule.javascript || '';
|
||||||
document.getElementById('css').value = rule.css || '';
|
document.getElementById('css').value = rule.css || '';
|
||||||
|
document.getElementById('autoClickSelector').value = rule.autoClickSelector || '';
|
||||||
|
document.getElementById('autoClickInterval').value = rule.autoClickIntervalSec || 0;
|
||||||
|
document.getElementById('autoClickMax').value = rule.autoClickMax || 0;
|
||||||
|
document.getElementById('reapplyOnDomChanges').checked = !!rule.reapplyOnDomChanges;
|
||||||
document.getElementById('ruleEnabled').checked = rule.enabled;
|
document.getElementById('ruleEnabled').checked = rule.enabled;
|
||||||
|
renderReplacementRows(rule.textReplacements || []);
|
||||||
|
|
||||||
|
if (rule.autoClickSelector || (rule.textReplacements && rule.textReplacements.length) || rule.reapplyOnDomChanges) {
|
||||||
|
document.getElementById('advancedSection').open = true;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
modalTitle.textContent = 'Add Rule';
|
modalTitle.textContent = 'Add Rule';
|
||||||
document.getElementById('ruleEnabled').checked = true;
|
document.getElementById('ruleEnabled').checked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateFieldVisibility(document.getElementById('matchType').value);
|
updateFieldVisibility(document.getElementById('matchType').value);
|
||||||
|
showRuleModal();
|
||||||
// Show modal - ensure Bootstrap Modal is initialized
|
}
|
||||||
|
|
||||||
|
function showRuleModal() {
|
||||||
if (ruleModal) {
|
if (ruleModal) {
|
||||||
ruleModal.show();
|
ruleModal.show();
|
||||||
} else {
|
} else {
|
||||||
// Fallback: try to initialize and show
|
|
||||||
try {
|
try {
|
||||||
ruleModal = new bootstrap.Modal(document.getElementById('ruleModal'));
|
ruleModal = new bootstrap.Modal(document.getElementById('ruleModal'));
|
||||||
ruleModal.show();
|
ruleModal.show();
|
||||||
@@ -225,8 +238,12 @@ function saveRule() {
|
|||||||
const javascript = document.getElementById('javascript').value;
|
const javascript = document.getElementById('javascript').value;
|
||||||
const css = document.getElementById('css').value;
|
const css = document.getElementById('css').value;
|
||||||
const enabled = document.getElementById('ruleEnabled').checked;
|
const enabled = document.getElementById('ruleEnabled').checked;
|
||||||
|
const autoClickSelector = document.getElementById('autoClickSelector').value.trim();
|
||||||
|
const autoClickIntervalSec = parseInt(document.getElementById('autoClickInterval').value, 10) || 0;
|
||||||
|
const autoClickMax = parseInt(document.getElementById('autoClickMax').value, 10) || 0;
|
||||||
|
const reapplyOnDomChanges = document.getElementById('reapplyOnDomChanges').checked;
|
||||||
|
const textReplacements = collectReplacementRows().filter((r) => r.find.trim());
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
alert('Please enter a rule name');
|
alert('Please enter a rule name');
|
||||||
return;
|
return;
|
||||||
@@ -247,19 +264,14 @@ function saveRule() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!javascript && !css) {
|
if (!javascript && !css && !autoClickSelector && textReplacements.length === 0) {
|
||||||
alert('Please enter either JavaScript or CSS code');
|
alert('Please enter JavaScript, CSS, an auto-click selector, or a text replacement');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rule = {
|
const rule = {
|
||||||
name,
|
name, matchType, urlPattern, elementSelector, javascript, css, enabled,
|
||||||
matchType,
|
autoClickSelector, autoClickIntervalSec, autoClickMax, reapplyOnDomChanges, textReplacements
|
||||||
urlPattern,
|
|
||||||
elementSelector,
|
|
||||||
javascript,
|
|
||||||
css,
|
|
||||||
enabled
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingRuleIndex >= 0) {
|
if (editingRuleIndex >= 0) {
|
||||||
@@ -268,7 +280,7 @@ function saveRule() {
|
|||||||
currentRules.push(rule);
|
currentRules.push(rule);
|
||||||
}
|
}
|
||||||
|
|
||||||
chrome.storage.sync.set({ rules: currentRules }, () => {
|
chrome.storage.sync.set({ rules: currentRules }).then(() => {
|
||||||
loadRules();
|
loadRules();
|
||||||
ruleModal.hide();
|
ruleModal.hide();
|
||||||
});
|
});
|
||||||
@@ -281,7 +293,7 @@ function editRule(index) {
|
|||||||
function deleteRule(index) {
|
function deleteRule(index) {
|
||||||
if (confirm(`Delete rule "${currentRules[index].name}"?`)) {
|
if (confirm(`Delete rule "${currentRules[index].name}"?`)) {
|
||||||
currentRules.splice(index, 1);
|
currentRules.splice(index, 1);
|
||||||
chrome.storage.sync.set({ rules: currentRules }, () => {
|
chrome.storage.sync.set({ rules: currentRules }).then(() => {
|
||||||
loadRules();
|
loadRules();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -289,14 +301,392 @@ function deleteRule(index) {
|
|||||||
|
|
||||||
function toggleRule(index) {
|
function toggleRule(index) {
|
||||||
currentRules[index].enabled = !currentRules[index].enabled;
|
currentRules[index].enabled = !currentRules[index].enabled;
|
||||||
chrome.storage.sync.set({ rules: currentRules }, () => {
|
chrome.storage.sync.set({ rules: currentRules }).then(() => {
|
||||||
loadRules();
|
loadRules();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text == null ? '' : text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Advanced rule fields: text replacement rows + the "pick on page" button
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function setupAdvancedRuleFields() {
|
||||||
|
document.getElementById('addReplacementBtn').addEventListener('click', () => {
|
||||||
|
const rows = collectReplacementRows();
|
||||||
|
rows.push({ find: '', replace: '' });
|
||||||
|
renderReplacementRows(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('textReplacementsList').addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.remove-replacement-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
const rows = collectReplacementRows();
|
||||||
|
const index = parseInt(btn.closest('.replacement-row').dataset.index, 10);
|
||||||
|
rows.splice(index, 1);
|
||||||
|
renderReplacementRows(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('pickElementBtn').addEventListener('click', async () => {
|
||||||
|
const tab = await getActiveTab();
|
||||||
|
if (!tab || !tab.id) return;
|
||||||
|
|
||||||
|
await chrome.storage.local.set({ pickerPending: collectModalSnapshot() });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await chrome.tabs.sendMessage(tab.id, { action: 'startPicker' });
|
||||||
|
} catch (e) {
|
||||||
|
alert('Could not start the element picker on this page (it may be a restricted browser page).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The popup closes as soon as focus moves to the page, so there's nothing more
|
||||||
|
// to do here — the picked selector is relayed back through storage and restored
|
||||||
|
// the next time this popup opens (see restorePendingPicker()).
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReplacementRows(rows) {
|
||||||
|
const list = document.getElementById('textReplacementsList');
|
||||||
|
list.innerHTML = rows.map((r, i) => `
|
||||||
|
<div class="input-group input-group-sm mb-1 replacement-row" data-index="${i}">
|
||||||
|
<input type="text" class="form-control replacement-find" placeholder="Find" value="${escapeHtml(r.find || '')}">
|
||||||
|
<input type="text" class="form-control replacement-replace" placeholder="Replace with" value="${escapeHtml(r.replace || '')}">
|
||||||
|
<button class="btn btn-outline-danger remove-replacement-btn" type="button" title="Remove">✕</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectReplacementRows() {
|
||||||
|
return [...document.querySelectorAll('.replacement-row')].map((row) => ({
|
||||||
|
find: row.querySelector('.replacement-find').value,
|
||||||
|
replace: row.querySelector('.replacement-replace').value
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectModalSnapshot() {
|
||||||
|
return {
|
||||||
|
modalTitle: document.getElementById('modalTitle').textContent,
|
||||||
|
editingRuleIndex,
|
||||||
|
ruleName: document.getElementById('ruleName').value,
|
||||||
|
matchType: document.getElementById('matchType').value,
|
||||||
|
urlPattern: document.getElementById('urlPattern').value,
|
||||||
|
elementSelector: document.getElementById('elementSelector').value,
|
||||||
|
javascript: document.getElementById('javascript').value,
|
||||||
|
css: document.getElementById('css').value,
|
||||||
|
autoClickSelector: document.getElementById('autoClickSelector').value,
|
||||||
|
autoClickInterval: document.getElementById('autoClickInterval').value,
|
||||||
|
autoClickMax: document.getElementById('autoClickMax').value,
|
||||||
|
reapplyOnDomChanges: document.getElementById('reapplyOnDomChanges').checked,
|
||||||
|
textReplacements: collectReplacementRows(),
|
||||||
|
ruleEnabled: document.getElementById('ruleEnabled').checked
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreModalState(snapshot, pickedSelector) {
|
||||||
|
editingRuleIndex = snapshot.editingRuleIndex;
|
||||||
|
document.getElementById('modalTitle').textContent = snapshot.modalTitle;
|
||||||
|
document.getElementById('ruleName').value = snapshot.ruleName;
|
||||||
|
document.getElementById('matchType').value = snapshot.matchType;
|
||||||
|
document.getElementById('urlPattern').value = snapshot.urlPattern;
|
||||||
|
document.getElementById('elementSelector').value = pickedSelector || snapshot.elementSelector;
|
||||||
|
document.getElementById('javascript').value = snapshot.javascript;
|
||||||
|
document.getElementById('css').value = snapshot.css;
|
||||||
|
document.getElementById('autoClickSelector').value = snapshot.autoClickSelector;
|
||||||
|
document.getElementById('autoClickInterval').value = snapshot.autoClickInterval;
|
||||||
|
document.getElementById('autoClickMax').value = snapshot.autoClickMax;
|
||||||
|
document.getElementById('reapplyOnDomChanges').checked = snapshot.reapplyOnDomChanges;
|
||||||
|
document.getElementById('ruleEnabled').checked = snapshot.ruleEnabled;
|
||||||
|
renderReplacementRows(snapshot.textReplacements || []);
|
||||||
|
document.getElementById('advancedSection').open = true;
|
||||||
|
updateFieldVisibility(snapshot.matchType);
|
||||||
|
showRuleModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function restorePendingPicker() {
|
||||||
|
chrome.storage.local.get(['pickerPending', 'pendingPickResult']).then((result) => {
|
||||||
|
if (!result.pickerPending) return;
|
||||||
|
restoreModalState(result.pickerPending, result.pendingPickResult || null);
|
||||||
|
chrome.storage.local.remove(['pickerPending', 'pendingPickResult']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Header controls: master toggle, dark mode quick action, import/export
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function setupHeaderControls() {
|
||||||
|
const masterToggle = document.getElementById('masterToggle');
|
||||||
|
const masterToggleLabel = document.getElementById('masterToggleLabel');
|
||||||
|
|
||||||
|
chrome.storage.sync.get(['masterEnabled']).then((result) => {
|
||||||
|
const enabled = result.masterEnabled !== false;
|
||||||
|
masterToggle.checked = enabled;
|
||||||
|
masterToggleLabel.textContent = enabled ? 'On' : 'Off';
|
||||||
|
});
|
||||||
|
|
||||||
|
masterToggle.addEventListener('change', () => {
|
||||||
|
chrome.storage.sync.set({ masterEnabled: masterToggle.checked }).then(() => {
|
||||||
|
masterToggleLabel.textContent = masterToggle.checked ? 'On' : 'Off';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('darkModeBtn').addEventListener('click', addDarkModeRule);
|
||||||
|
document.getElementById('exportRulesBtn').addEventListener('click', exportRules);
|
||||||
|
document.getElementById('importRulesBtn').addEventListener('click', () => {
|
||||||
|
document.getElementById('importFileInput').click();
|
||||||
|
});
|
||||||
|
document.getElementById('importFileInput').addEventListener('change', importRules);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addDarkModeRule() {
|
||||||
|
const tab = await getActiveTab();
|
||||||
|
if (!tab || !tab.url) return;
|
||||||
|
|
||||||
|
let hostname;
|
||||||
|
try {
|
||||||
|
hostname = new URL(tab.url).hostname;
|
||||||
|
} catch (e) {
|
||||||
|
alert('Cannot create a dark mode rule for this page.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hostname) {
|
||||||
|
alert('Cannot create a dark mode rule for this page.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rule = {
|
||||||
|
name: `Dark Mode: ${hostname}`,
|
||||||
|
matchType: 'url',
|
||||||
|
urlPattern: hostname,
|
||||||
|
elementSelector: '',
|
||||||
|
javascript: '',
|
||||||
|
css: 'html { filter: invert(1) hue-rotate(180deg); }\n' +
|
||||||
|
'img, video, picture, canvas, svg, [style*="background-image"] { filter: invert(1) hue-rotate(180deg); }',
|
||||||
|
enabled: true,
|
||||||
|
autoClickSelector: '',
|
||||||
|
autoClickIntervalSec: 0,
|
||||||
|
autoClickMax: 0,
|
||||||
|
reapplyOnDomChanges: false,
|
||||||
|
textReplacements: []
|
||||||
|
};
|
||||||
|
|
||||||
|
currentRules.push(rule);
|
||||||
|
chrome.storage.sync.set({ rules: currentRules }).then(() => {
|
||||||
|
loadRules();
|
||||||
|
alert(`Added a dark mode rule for ${hostname}. Reload the page to see it — edit or disable it any time from the Rules tab.`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportRules() {
|
||||||
|
downloadBlob(JSON.stringify(currentRules, null, 2), 'greasyboii-rules.json', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function importRules() {
|
||||||
|
const input = document.getElementById('importFileInput');
|
||||||
|
const file = input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
let imported;
|
||||||
|
try {
|
||||||
|
imported = JSON.parse(reader.result);
|
||||||
|
} catch (e) {
|
||||||
|
alert('That file is not valid JSON.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const looksLikeRules = Array.isArray(imported) &&
|
||||||
|
imported.every((r) => r && typeof r.name === 'string' && typeof r.matchType === 'string');
|
||||||
|
if (!looksLikeRules) {
|
||||||
|
alert('That file does not look like a GreasyBoii rules export.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Import ${imported.length} rule(s) from this file?`)) return;
|
||||||
|
const replace = confirm('Replace all existing rules with the imported ones?\n\nOK = Replace\nCancel = Add to existing rules');
|
||||||
|
currentRules = replace ? imported : currentRules.concat(imported);
|
||||||
|
|
||||||
|
chrome.storage.sync.set({ rules: currentRules }).then(loadRules);
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Tools tab: bulk media downloader, link harvester, page data scraper
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function setupToolsTab() {
|
||||||
|
setupMediaDownloader();
|
||||||
|
setupLinkHarvester();
|
||||||
|
setupDataScraper();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupMediaDownloader() {
|
||||||
|
const mediaType = document.getElementById('mediaType');
|
||||||
|
const mediaExtensions = document.getElementById('mediaExtensions');
|
||||||
|
const scanMediaBtn = document.getElementById('scanMediaBtn');
|
||||||
|
const mediaResults = document.getElementById('mediaResults');
|
||||||
|
const downloadMediaBtn = document.getElementById('downloadMediaBtn');
|
||||||
|
|
||||||
|
mediaType.addEventListener('change', () => {
|
||||||
|
mediaExtensions.style.display = mediaType.value === 'custom' ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
scanMediaBtn.addEventListener('click', async () => {
|
||||||
|
const tab = await getActiveTab();
|
||||||
|
if (!tab) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await chrome.tabs.sendMessage(tab.id, {
|
||||||
|
action: 'scanMedia',
|
||||||
|
mediaType: mediaType.value,
|
||||||
|
extensions: mediaExtensions.value
|
||||||
|
});
|
||||||
|
scannedMedia = (response && response.items) || [];
|
||||||
|
mediaResults.textContent = `${scannedMedia.length} file(s) found.`;
|
||||||
|
downloadMediaBtn.disabled = scannedMedia.length === 0;
|
||||||
|
downloadMediaBtn.textContent = `Download All (${scannedMedia.length})`;
|
||||||
|
} catch (e) {
|
||||||
|
mediaResults.textContent = 'Could not scan this page (try reloading it first).';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
downloadMediaBtn.addEventListener('click', () => {
|
||||||
|
scannedMedia.forEach((item, i) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
chrome.downloads.download({ url: item.url, filename: item.filename, conflictAction: 'uniquify' })
|
||||||
|
.catch((e) => console.error('[GreasyBoii] Download failed for', item.url, e));
|
||||||
|
}, i * 150);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupLinkHarvester() {
|
||||||
|
const containerSelector = document.getElementById('linkContainerSelector');
|
||||||
|
const harvestLinksBtn = document.getElementById('harvestLinksBtn');
|
||||||
|
const linkResults = document.getElementById('linkResults');
|
||||||
|
const openLinksBtn = document.getElementById('openLinksBtn');
|
||||||
|
const exportLinksBtn = document.getElementById('exportLinksBtn');
|
||||||
|
|
||||||
|
harvestLinksBtn.addEventListener('click', async () => {
|
||||||
|
const tab = await getActiveTab();
|
||||||
|
if (!tab) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await chrome.tabs.sendMessage(tab.id, {
|
||||||
|
action: 'harvestLinks',
|
||||||
|
containerSelector: containerSelector.value.trim()
|
||||||
|
});
|
||||||
|
harvestedLinks = (response && response.items) || [];
|
||||||
|
renderLinkResults();
|
||||||
|
} catch (e) {
|
||||||
|
linkResults.textContent = 'Could not read links on this page (try reloading it first).';
|
||||||
|
openLinksBtn.disabled = true;
|
||||||
|
exportLinksBtn.disabled = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderLinkResults() {
|
||||||
|
if (harvestedLinks.length === 0) {
|
||||||
|
linkResults.textContent = 'No links found.';
|
||||||
|
openLinksBtn.disabled = true;
|
||||||
|
exportLinksBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
linkResults.innerHTML = `<div class="tool-scroll">${harvestedLinks.map((link, i) => `
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input link-check" type="checkbox" id="link-${i}" data-index="${i}" checked>
|
||||||
|
<label class="form-check-label text-truncate d-block" for="link-${i}" title="${escapeHtml(link.url)}">${escapeHtml(link.text || link.url)}</label>
|
||||||
|
</div>
|
||||||
|
`).join('')}</div><div class="text-muted mt-1">${harvestedLinks.length} link(s) found</div>`;
|
||||||
|
openLinksBtn.disabled = false;
|
||||||
|
exportLinksBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCheckedLinks() {
|
||||||
|
const checkedIndexes = [...document.querySelectorAll('.link-check:checked')].map((el) => parseInt(el.dataset.index, 10));
|
||||||
|
return harvestedLinks.filter((_, i) => checkedIndexes.includes(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
openLinksBtn.addEventListener('click', () => {
|
||||||
|
getCheckedLinks().forEach((link) => chrome.tabs.create({ url: link.url, active: false }));
|
||||||
|
});
|
||||||
|
|
||||||
|
exportLinksBtn.addEventListener('click', () => {
|
||||||
|
const text = getCheckedLinks().map((l) => l.url).join('\n');
|
||||||
|
downloadBlob(text, 'greasyboii-links.txt', 'text/plain');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupDataScraper() {
|
||||||
|
const selector = document.getElementById('scrapeSelector');
|
||||||
|
const attribute = document.getElementById('scrapeAttribute');
|
||||||
|
const customAttr = document.getElementById('scrapeCustomAttr');
|
||||||
|
const scrapeBtn = document.getElementById('scrapeBtn');
|
||||||
|
const scrapeResults = document.getElementById('scrapeResults');
|
||||||
|
const exportCsvBtn = document.getElementById('exportCsvBtn');
|
||||||
|
const exportJsonBtn = document.getElementById('exportJsonBtn');
|
||||||
|
|
||||||
|
attribute.addEventListener('change', () => {
|
||||||
|
customAttr.style.display = attribute.value === 'custom' ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
scrapeBtn.addEventListener('click', async () => {
|
||||||
|
const tab = await getActiveTab();
|
||||||
|
if (!tab || !selector.value.trim()) return;
|
||||||
|
|
||||||
|
const attr = attribute.value === 'custom' ? customAttr.value.trim() : attribute.value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await chrome.tabs.sendMessage(tab.id, {
|
||||||
|
action: 'scrapeData',
|
||||||
|
selector: selector.value.trim(),
|
||||||
|
attribute: attr
|
||||||
|
});
|
||||||
|
scrapedData = (response && response.items) || [];
|
||||||
|
scrapeResults.textContent = `${scrapedData.length} value(s) scraped.`;
|
||||||
|
exportCsvBtn.disabled = scrapedData.length === 0;
|
||||||
|
exportJsonBtn.disabled = scrapedData.length === 0;
|
||||||
|
} catch (e) {
|
||||||
|
scrapeResults.textContent = 'Could not scrape this page (try reloading it first).';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
exportCsvBtn.addEventListener('click', () => {
|
||||||
|
const csv = scrapedData.map((v) => `"${String(v).replace(/"/g, '""')}"`).join('\n');
|
||||||
|
downloadBlob(csv, 'greasyboii-scrape.csv', 'text/csv');
|
||||||
|
});
|
||||||
|
|
||||||
|
exportJsonBtn.addEventListener('click', () => {
|
||||||
|
downloadBlob(JSON.stringify(scrapedData, null, 2), 'greasyboii-scrape.json', 'application/json');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Shared helpers
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function getActiveTab() {
|
||||||
|
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||||
|
return tabs[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBlob(content, filename, mimeType) {
|
||||||
|
const blob = new Blob([content], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Background script for Firefox extension
|
// Background script for Firefox extension
|
||||||
browser.runtime.onInstalled.addListener(() => {
|
browser.runtime.onInstalled.addListener(() => {
|
||||||
console.log('GreasyBoii extension installed');
|
console.log('GreasyBoii extension installed');
|
||||||
|
refreshBadge();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for messages from content script
|
// Listen for messages from content script
|
||||||
@@ -11,4 +12,40 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
|||||||
});
|
});
|
||||||
return true; // Keep channel open for async response
|
return true; // Keep channel open for async response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The popup closes the instant the user clicks the page to pick an element, so the
|
||||||
|
// content script can't hand the selector straight back to it — relay it through
|
||||||
|
// storage instead; the popup picks it up next time it opens (see popup.js).
|
||||||
|
if (request.action === 'pickerResult') {
|
||||||
|
browser.storage.local.set({ pendingPickResult: request.selector });
|
||||||
|
} else if (request.action === 'pickerCancelled') {
|
||||||
|
browser.storage.local.remove(['pickerPending', 'pendingPickResult']);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Master enable/disable keyboard shortcut (see manifest.json "commands")
|
||||||
|
browser.commands.onCommand.addListener((command) => {
|
||||||
|
if (command === 'toggle-greasyboii') {
|
||||||
|
browser.storage.sync.get(['masterEnabled']).then((result) => {
|
||||||
|
const enabled = result.masterEnabled !== false;
|
||||||
|
browser.storage.sync.set({ masterEnabled: !enabled });
|
||||||
|
// badge refresh happens via the storage.onChanged listener below
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep the toolbar badge in sync whenever masterEnabled changes, from any source
|
||||||
|
// (the shortcut above, the popup's toggle switch, or a rules import)
|
||||||
|
browser.storage.onChanged.addListener((changes, area) => {
|
||||||
|
if (area === 'sync' && changes.masterEnabled) {
|
||||||
|
refreshBadge();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function refreshBadge() {
|
||||||
|
browser.storage.sync.get(['masterEnabled']).then((result) => {
|
||||||
|
const enabled = result.masterEnabled !== false;
|
||||||
|
browser.browserAction.setBadgeText({ text: enabled ? '' : 'OFF' });
|
||||||
|
browser.browserAction.setBadgeBackgroundColor({ color: '#dc3545' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,117 +1,370 @@
|
|||||||
// Content script that runs on all pages
|
// Content script that runs on all pages
|
||||||
(function() {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
console.log('[GreasyBoii] Content script loaded on:', window.location.href);
|
console.log('[GreasyBoii] Content script loaded on:', window.location.href);
|
||||||
|
|
||||||
// Wait for DOM to be ready
|
const ruleMatchState = new Map(); // rule name -> last known match state (edge-triggers re-apply)
|
||||||
|
const autoClickTimers = new Map(); // rule name -> setInterval id
|
||||||
|
const injectedStyles = new Map(); // rule name -> <style> element currently on the page
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', initGreasyBoii);
|
document.addEventListener('DOMContentLoaded', boot);
|
||||||
} else {
|
} else {
|
||||||
initGreasyBoii();
|
boot();
|
||||||
|
}
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
browser.storage.sync.get(['masterEnabled']).then((result) => {
|
||||||
|
if (result.masterEnabled === false) {
|
||||||
|
console.log('[GreasyBoii] Disabled via master toggle, skipping.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initGreasyBoii();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function initGreasyBoii() {
|
function initGreasyBoii() {
|
||||||
console.log('[GreasyBoii] Initializing...');
|
console.log('[GreasyBoii] Initializing...');
|
||||||
|
|
||||||
// Get rules from storage and execute matching ones
|
|
||||||
browser.storage.sync.get(['rules']).then((result) => {
|
browser.storage.sync.get(['rules']).then((result) => {
|
||||||
const rules = result.rules || [];
|
const rules = result.rules || [];
|
||||||
const currentUrl = window.location.href;
|
|
||||||
|
|
||||||
console.log('[GreasyBoii] Found', rules.length, 'rule(s) in storage');
|
console.log('[GreasyBoii] Found', rules.length, 'rule(s) in storage');
|
||||||
|
|
||||||
rules.forEach((rule) => {
|
rules.forEach(evaluateRule);
|
||||||
if (!rule.enabled) {
|
|
||||||
console.log('[GreasyBoii] Skipping disabled rule:', rule.name);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let shouldExecute = false;
|
if (rules.some((r) => r.enabled && r.reapplyOnDomChanges)) {
|
||||||
|
watchForChanges(rules.filter((r) => r.enabled && r.reapplyOnDomChanges));
|
||||||
// Check conditions
|
}
|
||||||
if (rule.matchType === 'url') {
|
|
||||||
shouldExecute = currentUrl.includes(rule.urlPattern);
|
|
||||||
console.log(`[GreasyBoii] URL match test for "${rule.name}": ${currentUrl} includes "${rule.urlPattern}" = ${shouldExecute}`);
|
|
||||||
} else if (rule.matchType === 'element') {
|
|
||||||
shouldExecute = document.querySelector(rule.elementSelector) !== null;
|
|
||||||
console.log(`[GreasyBoii] Element match test for "${rule.name}": selector "${rule.elementSelector}" found = ${shouldExecute}`);
|
|
||||||
} else if (rule.matchType === 'both') {
|
|
||||||
const urlMatches = currentUrl.includes(rule.urlPattern);
|
|
||||||
const elementExists = document.querySelector(rule.elementSelector) !== null;
|
|
||||||
shouldExecute = urlMatches && elementExists;
|
|
||||||
console.log(`[GreasyBoii] Combined match test for "${rule.name}": URL=${urlMatches}, Element=${elementExists}, Result=${shouldExecute}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldExecute) {
|
|
||||||
console.log(`[GreasyBoii] ✓ Executing rule: ${rule.name}`);
|
|
||||||
executeRule(rule);
|
|
||||||
} else {
|
|
||||||
console.log(`[GreasyBoii] ✗ Rule "${rule.name}" conditions not met`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function executeRule(rule) {
|
function ruleMatches(rule) {
|
||||||
// For Firefox, we'll use exportFunction and cloneInto to bypass CSP
|
const currentUrl = window.location.href;
|
||||||
if (rule.javascript && rule.javascript.trim()) {
|
if (rule.matchType === 'url') {
|
||||||
console.log(`[GreasyBoii] Injecting JavaScript for rule: ${rule.name}`);
|
return currentUrl.includes(rule.urlPattern);
|
||||||
|
} else if (rule.matchType === 'element') {
|
||||||
try {
|
return !!document.querySelector(rule.elementSelector);
|
||||||
// Method 1: Try wrappedJSObject (Firefox specific)
|
} else if (rule.matchType === 'both') {
|
||||||
if (typeof wrappedJSObject !== 'undefined') {
|
return currentUrl.includes(rule.urlPattern) && !!document.querySelector(rule.elementSelector);
|
||||||
try {
|
|
||||||
const code = `
|
|
||||||
(function() {
|
|
||||||
try {
|
|
||||||
${rule.javascript}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[GreasyBoii] Error in injected script:', e);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
`;
|
|
||||||
wrappedJSObject.eval(code);
|
|
||||||
console.log(`[GreasyBoii] ✓ JavaScript executed via wrappedJSObject`);
|
|
||||||
return;
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`[GreasyBoii] wrappedJSObject method failed, trying alternative...`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method 2: Script injection (works on most sites)
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.textContent = `
|
|
||||||
(function() {
|
|
||||||
try {
|
|
||||||
${rule.javascript}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[GreasyBoii] Error in injected script:', e);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
`;
|
|
||||||
(document.head || document.documentElement).appendChild(script);
|
|
||||||
script.remove();
|
|
||||||
console.log(`[GreasyBoii] ✓ JavaScript injected successfully`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[GreasyBoii] Error executing JavaScript for rule "${rule.name}":`, error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Execute CSS - CSS injection doesn't have CSP issues
|
function evaluateRule(rule) {
|
||||||
if (rule.css && rule.css.trim()) {
|
if (!rule.enabled) return;
|
||||||
try {
|
|
||||||
console.log(`[GreasyBoii] Injecting CSS for rule: ${rule.name}`);
|
const matched = ruleMatches(rule);
|
||||||
|
const wasMatched = ruleMatchState.get(rule.name) === true;
|
||||||
const style = document.createElement('style');
|
ruleMatchState.set(rule.name, matched);
|
||||||
style.textContent = rule.css;
|
|
||||||
style.setAttribute('data-greasyboii-rule', rule.name);
|
if (matched && !wasMatched) {
|
||||||
(document.head || document.documentElement).appendChild(style);
|
console.log(`[GreasyBoii] ✓ Executing rule: ${rule.name}`);
|
||||||
|
executeRule(rule);
|
||||||
console.log(`[GreasyBoii] ✓ CSS injected successfully`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[GreasyBoii] Error applying CSS for rule "${rule.name}":`, error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ponytail: fixed 400ms debounce, not adaptive to mutation volume — fine for the
|
||||||
|
// "route just changed in an SPA" case this exists for; a hot mutation stream would
|
||||||
|
// want per-rule throttling instead.
|
||||||
|
function watchForChanges(watchedRules) {
|
||||||
|
let debounceTimer = null;
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(() => watchedRules.forEach(evaluateRule), 400);
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeRule(rule) {
|
||||||
|
if (rule.javascript && rule.javascript.trim()) {
|
||||||
|
injectJavaScript(rule);
|
||||||
|
}
|
||||||
|
if (rule.css && rule.css.trim()) {
|
||||||
|
injectCss(rule);
|
||||||
|
}
|
||||||
|
if (rule.autoClickSelector && rule.autoClickSelector.trim()) {
|
||||||
|
runAutoClick(rule);
|
||||||
|
}
|
||||||
|
if (rule.textReplacements && rule.textReplacements.length) {
|
||||||
|
runTextReplacements(rule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectJavaScript(rule) {
|
||||||
|
console.log(`[GreasyBoii] Injecting JavaScript for rule: ${rule.name}`);
|
||||||
|
try {
|
||||||
|
// Method 1: wrappedJSObject (Firefox specific, reaches the page's real global scope)
|
||||||
|
if (typeof wrappedJSObject !== 'undefined') {
|
||||||
|
try {
|
||||||
|
const code = `
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
${rule.javascript}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[GreasyBoii] Error in injected script:', e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
wrappedJSObject.eval(code);
|
||||||
|
console.log('[GreasyBoii] ✓ JavaScript executed via wrappedJSObject');
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[GreasyBoii] wrappedJSObject method failed, trying alternative...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method 2: script tag injection (works on most sites)
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.textContent = `
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
${rule.javascript}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[GreasyBoii] Error in injected script:', e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
(document.head || document.documentElement).appendChild(script);
|
||||||
|
script.remove();
|
||||||
|
console.log('[GreasyBoii] ✓ JavaScript injected successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[GreasyBoii] Error executing JavaScript for rule "${rule.name}":`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectCss(rule) {
|
||||||
|
try {
|
||||||
|
const old = injectedStyles.get(rule.name);
|
||||||
|
if (old) old.remove();
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = rule.css;
|
||||||
|
style.setAttribute('data-greasyboii-rule', rule.name);
|
||||||
|
(document.head || document.documentElement).appendChild(style);
|
||||||
|
injectedStyles.set(rule.name, style);
|
||||||
|
|
||||||
|
console.log(`[GreasyBoii] ✓ CSS injected for rule: ${rule.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[GreasyBoii] Error applying CSS for rule "${rule.name}":`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAutoClick(rule) {
|
||||||
|
const key = rule.name;
|
||||||
|
if (autoClickTimers.has(key)) return; // already running for this rule instance
|
||||||
|
|
||||||
|
const intervalSec = Number(rule.autoClickIntervalSec) || 0;
|
||||||
|
const max = Number(rule.autoClickMax) || 0;
|
||||||
|
let clicks = 0;
|
||||||
|
let timerId = null;
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (timerId) clearInterval(timerId);
|
||||||
|
autoClickTimers.delete(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clickOnce = () => {
|
||||||
|
const el = document.querySelector(rule.autoClickSelector);
|
||||||
|
if (el) {
|
||||||
|
el.click();
|
||||||
|
clicks++;
|
||||||
|
console.log(`[GreasyBoii] Auto-clicked "${rule.autoClickSelector}" for rule "${rule.name}" (${clicks})`);
|
||||||
|
}
|
||||||
|
if (max > 0 && clicks >= max) stop();
|
||||||
|
};
|
||||||
|
|
||||||
|
clickOnce();
|
||||||
|
if (intervalSec > 0 && (max === 0 || clicks < max)) {
|
||||||
|
timerId = setInterval(clickOnce, intervalSec * 1000);
|
||||||
|
autoClickTimers.set(key, timerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTextReplacements(rule) {
|
||||||
|
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
|
||||||
|
acceptNode(node) {
|
||||||
|
const parentTag = node.parentElement && node.parentElement.tagName;
|
||||||
|
return parentTag === 'SCRIPT' || parentTag === 'STYLE'
|
||||||
|
? NodeFilter.FILTER_REJECT
|
||||||
|
: NodeFilter.FILTER_ACCEPT;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const nodes = [];
|
||||||
|
let node;
|
||||||
|
while ((node = walker.nextNode())) nodes.push(node);
|
||||||
|
|
||||||
|
nodes.forEach((textNode) => {
|
||||||
|
let text = textNode.nodeValue;
|
||||||
|
let changed = false;
|
||||||
|
rule.textReplacements.forEach(({ find, replace }) => {
|
||||||
|
if (find && text.includes(find)) {
|
||||||
|
text = text.split(find).join(replace || '');
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (changed) textNode.nodeValue = text;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[GreasyBoii] Applied text replacements for rule: ${rule.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// On-demand tools (popup "Tools" tab talks to whichever tab is active)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||||
|
if (request.action === 'startPicker') {
|
||||||
|
startElementPicker();
|
||||||
|
} else if (request.action === 'scanMedia') {
|
||||||
|
sendResponse({ items: scanMedia(request.mediaType, request.extensions) });
|
||||||
|
} else if (request.action === 'harvestLinks') {
|
||||||
|
sendResponse({ items: harvestLinks(request.containerSelector) });
|
||||||
|
} else if (request.action === 'scrapeData') {
|
||||||
|
sendResponse({ items: scrapeData(request.selector, request.attribute) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function scanMedia(mediaType, customExtensions) {
|
||||||
|
const extMap = {
|
||||||
|
images: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'avif'],
|
||||||
|
pdfs: ['pdf'],
|
||||||
|
videos: ['mp4', 'webm', 'mov', 'avi', 'mkv']
|
||||||
|
};
|
||||||
|
const extensions = mediaType === 'custom'
|
||||||
|
? (customExtensions || '').split(',').map((e) => e.trim().toLowerCase()).filter(Boolean)
|
||||||
|
: (extMap[mediaType] || []);
|
||||||
|
|
||||||
|
const urls = new Set();
|
||||||
|
|
||||||
|
if (mediaType === 'images') {
|
||||||
|
document.querySelectorAll('img[src]').forEach((img) => urls.add(img.src));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('a[href]').forEach((a) => {
|
||||||
|
const ext = (a.href.split('?')[0].split('.').pop() || '').toLowerCase();
|
||||||
|
if (extensions.includes(ext)) urls.add(a.href);
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...urls].map((url) => ({ url, filename: url.split('/').pop().split('?')[0] || 'file' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function harvestLinks(containerSelector) {
|
||||||
|
const root = (containerSelector && containerSelector.trim())
|
||||||
|
? document.querySelector(containerSelector)
|
||||||
|
: document;
|
||||||
|
if (!root) return [];
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
const links = [];
|
||||||
|
root.querySelectorAll('a[href]').forEach((a) => {
|
||||||
|
if (!seen.has(a.href)) {
|
||||||
|
seen.add(a.href);
|
||||||
|
links.push({ url: a.href, text: (a.textContent || '').trim().slice(0, 80) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return links;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrapeData(selector, attribute) {
|
||||||
|
if (!selector) return [];
|
||||||
|
return [...document.querySelectorAll(selector)].map((el) => {
|
||||||
|
if (attribute === 'text') return el.textContent.trim();
|
||||||
|
if (attribute === 'href') return el.href || '';
|
||||||
|
if (attribute === 'src') return el.src || '';
|
||||||
|
return el.getAttribute(attribute) || '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Visual element picker
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
let pickerHighlightEl = null;
|
||||||
|
let pickerBanner = null;
|
||||||
|
|
||||||
|
function startElementPicker() {
|
||||||
|
if (pickerBanner) return; // already active
|
||||||
|
|
||||||
|
pickerBanner = document.createElement('div');
|
||||||
|
pickerBanner.textContent = 'GreasyBoii: click an element to select it — Esc to cancel';
|
||||||
|
pickerBanner.style.cssText =
|
||||||
|
'position:fixed;top:0;left:0;right:0;z-index:2147483647;background:#ff8a1e;' +
|
||||||
|
'color:#111;font:600 13px/1.4 sans-serif;text-align:center;padding:8px;pointer-events:none;';
|
||||||
|
document.documentElement.appendChild(pickerBanner);
|
||||||
|
|
||||||
|
document.addEventListener('mouseover', onPickerHover, true);
|
||||||
|
document.addEventListener('click', onPickerClick, true);
|
||||||
|
document.addEventListener('keydown', onPickerKeydown, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopElementPicker() {
|
||||||
|
if (pickerHighlightEl) {
|
||||||
|
pickerHighlightEl.style.outline = '';
|
||||||
|
pickerHighlightEl = null;
|
||||||
|
}
|
||||||
|
if (pickerBanner) {
|
||||||
|
pickerBanner.remove();
|
||||||
|
pickerBanner = null;
|
||||||
|
}
|
||||||
|
document.removeEventListener('mouseover', onPickerHover, true);
|
||||||
|
document.removeEventListener('click', onPickerClick, true);
|
||||||
|
document.removeEventListener('keydown', onPickerKeydown, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickerHover(e) {
|
||||||
|
if (pickerHighlightEl) pickerHighlightEl.style.outline = '';
|
||||||
|
pickerHighlightEl = e.target;
|
||||||
|
pickerHighlightEl.style.outline = '2px solid #ff8a1e';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickerClick(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const selector = computeSelector(e.target);
|
||||||
|
stopElementPicker();
|
||||||
|
browser.runtime.sendMessage({ action: 'pickerResult', selector });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickerKeydown(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
stopElementPicker();
|
||||||
|
browser.runtime.sendMessage({ action: 'pickerCancelled' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ponytail: heuristic selector — prefers a unique #id, else walks up to 4 ancestors
|
||||||
|
// combining tag + first non-numeric class + :nth-of-type until the candidate is
|
||||||
|
// unique on the page. Not guaranteed minimal or unique on pathological DOMs (e.g.
|
||||||
|
// frameworks that only emit hashed/numeric class names); good enough for "point at
|
||||||
|
// a thing, get a usable selector" — upgrade to a proper CSS-selector library if that
|
||||||
|
// ever becomes a real problem.
|
||||||
|
function computeSelector(el) {
|
||||||
|
if (el.id) {
|
||||||
|
const idSelector = `#${CSS.escape(el.id)}`;
|
||||||
|
if (document.querySelectorAll(idSelector).length === 1) return idSelector;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
let node = el;
|
||||||
|
for (let depth = 0; node && node.nodeType === 1 && depth < 4; depth++) {
|
||||||
|
let part = node.tagName.toLowerCase();
|
||||||
|
const cls = [...node.classList].find((c) => c && !/\d/.test(c));
|
||||||
|
if (cls) {
|
||||||
|
part += `.${CSS.escape(cls)}`;
|
||||||
|
} else if (node.parentElement) {
|
||||||
|
const siblings = [...node.parentElement.children].filter((c) => c.tagName === node.tagName);
|
||||||
|
if (siblings.length > 1) {
|
||||||
|
part += `:nth-of-type(${siblings.indexOf(node) + 1})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.unshift(part);
|
||||||
|
|
||||||
|
const candidate = parts.join(' > ');
|
||||||
|
if (document.querySelectorAll(candidate).length === 1) return candidate;
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
return parts.join(' > ');
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2b2d31"/>
|
||||||
|
<stop offset="1" stop-color="#111214"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="bolt" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#ffcf5c"/>
|
||||||
|
<stop offset="1" stop-color="#ff8a1e"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="4" y="4" width="120" height="120" rx="28" fill="url(#bg)"/>
|
||||||
|
<rect x="4" y="4" width="120" height="120" rx="28" fill="none" stroke="#000000" stroke-opacity="0.35" stroke-width="2"/>
|
||||||
|
<polygon points="68,24 28,72 64,72 60,104 100,56 64,56 68,24" fill="url(#bolt)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 711 B |
|
Before Width: | Height: | Size: 499 B After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 115 B After Width: | Height: | Size: 527 B |
|
Before Width: | Height: | Size: 202 B After Width: | Height: | Size: 1.4 KiB |
@@ -1,17 +1,27 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 2,
|
"manifest_version": 2,
|
||||||
"name": "GreasyBoii",
|
"name": "GreasyBoii",
|
||||||
"version": "1.0.4",
|
"version": "2.0.0",
|
||||||
"description": "Execute custom JavaScript and CSS based on URL patterns and page elements",
|
"description": "Execute custom JavaScript and CSS based on URL patterns and page elements, plus one-click page tools: bulk downloads, link harvesting, scraping and more",
|
||||||
"author": "Andy Dixon",
|
"author": "Andy Dixon",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"storage",
|
"storage",
|
||||||
"activeTab",
|
"activeTab",
|
||||||
|
"downloads",
|
||||||
"<all_urls>"
|
"<all_urls>"
|
||||||
],
|
],
|
||||||
"background": {
|
"background": {
|
||||||
"scripts": ["background.js"]
|
"scripts": ["background.js"]
|
||||||
},
|
},
|
||||||
|
"commands": {
|
||||||
|
"toggle-greasyboii": {
|
||||||
|
"suggested_key": {
|
||||||
|
"default": "Ctrl+Shift+G",
|
||||||
|
"mac": "Command+Shift+G"
|
||||||
|
},
|
||||||
|
"description": "Enable/disable all GreasyBoii rules"
|
||||||
|
}
|
||||||
|
},
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
"matches": ["<all_urls>"],
|
"matches": ["<all_urls>"],
|
||||||
|
|||||||
@@ -1,25 +1,153 @@
|
|||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* GreasyBoii — grease-monkey garage theme, layered on Bootstrap dark */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--gm-bg: #16140f;
|
||||||
|
--gm-panel: #232019;
|
||||||
|
--gm-panel-alt: #1b1810;
|
||||||
|
--gm-panel-border: #4a3f2c;
|
||||||
|
--gm-amber: #ffb020;
|
||||||
|
--gm-amber-soft: #ffcf5c;
|
||||||
|
--gm-amber-deep: #ff8a1e;
|
||||||
|
--gm-text: #f1e9da;
|
||||||
|
--gm-text-muted: #b9ac93;
|
||||||
|
--gm-success: #6fae4a;
|
||||||
|
--gm-danger: #d9534f;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
width: 600px;
|
width: 600px;
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
max-height: 600px;
|
max-height: 600px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at top left, rgba(255, 138, 30, 0.08), transparent 55%),
|
||||||
|
radial-gradient(ellipse at bottom right, rgba(0, 0, 0, 0.5), transparent 60%),
|
||||||
|
var(--gm-bg);
|
||||||
|
color: var(--gm-text);
|
||||||
|
scrollbar-color: var(--gm-amber-deep) var(--gm-bg);
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rules-list {
|
::-webkit-scrollbar {
|
||||||
max-height: 450px;
|
width: 8px;
|
||||||
overflow-y: auto;
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--gm-bg);
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--gm-panel-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--gm-amber-deep);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-card {
|
/* ---------------- Brand header ---------------- */
|
||||||
border: 1px solid #495057;
|
|
||||||
border-radius: 0.375rem;
|
.brand-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-family: 'Arial Narrow', 'Helvetica Neue Condensed', Arial, sans-serif;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: linear-gradient(180deg, var(--gm-amber-soft), var(--gm-amber-deep));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-tagline {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--gm-text-muted);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hazard-strip {
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
135deg,
|
||||||
|
var(--gm-amber) 0 10px,
|
||||||
|
#171512 10px 20px
|
||||||
|
);
|
||||||
|
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Tabs ---------------- */
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
border-bottom-color: var(--gm-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs .nav-link {
|
||||||
|
color: var(--gm-text-muted);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs .nav-link:hover {
|
||||||
|
color: var(--gm-amber);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs .nav-link.active {
|
||||||
|
color: var(--gm-amber);
|
||||||
|
background: transparent;
|
||||||
|
border-bottom-color: var(--gm-amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Panels: rule cards + tool cards ---------------- */
|
||||||
|
|
||||||
|
.rule-card,
|
||||||
|
.tool-card {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid var(--gm-panel-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
background-color: #212529;
|
background: linear-gradient(180deg, var(--gm-panel), var(--gm-panel-alt));
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 2px 6px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-card::before,
|
||||||
|
.rule-card::after,
|
||||||
|
.tool-card::before,
|
||||||
|
.tool-card::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 7px;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 35% 35%, #7a7166, #2a2620);
|
||||||
|
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
.rule-card::before,
|
||||||
|
.tool-card::before {
|
||||||
|
left: 7px;
|
||||||
|
}
|
||||||
|
.rule-card::after,
|
||||||
|
.tool-card::after {
|
||||||
|
right: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-card:hover {
|
.rule-card:hover {
|
||||||
border-color: #6c757d;
|
border-color: var(--gm-amber-deep);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-header {
|
.rule-header {
|
||||||
@@ -30,8 +158,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rule-name {
|
.rule-name {
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
font-size: 1.1rem;
|
font-size: 1.05rem;
|
||||||
|
color: var(--gm-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-actions {
|
.rule-actions {
|
||||||
@@ -41,25 +170,152 @@ body {
|
|||||||
|
|
||||||
.rule-details {
|
.rule-details {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #adb5bd;
|
color: var(--gm-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rule-badge {
|
.rule-badge {
|
||||||
font-size: 0.75rem;
|
font-size: 0.7rem;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- Buttons ---------------- */
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(180deg, var(--gm-amber-soft), var(--gm-amber-deep));
|
||||||
|
border-color: #c9670f;
|
||||||
|
color: #201705;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4), 0 2px 4px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
.btn-primary:hover,
|
||||||
|
.btn-primary:focus {
|
||||||
|
background: linear-gradient(180deg, #ffdb7a, #ff9a3a);
|
||||||
|
border-color: #c9670f;
|
||||||
|
color: #201705;
|
||||||
|
}
|
||||||
|
.btn-primary:active {
|
||||||
|
background: linear-gradient(180deg, var(--gm-amber-deep), #e8730a);
|
||||||
|
color: #201705;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-light {
|
||||||
|
border-color: var(--gm-panel-border);
|
||||||
|
color: var(--gm-text);
|
||||||
|
}
|
||||||
|
.btn-outline-light:hover,
|
||||||
|
.btn-outline-light:focus {
|
||||||
|
background: var(--gm-amber);
|
||||||
|
border-color: var(--gm-amber);
|
||||||
|
color: #201705;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Forms ---------------- */
|
||||||
|
|
||||||
.font-monospace {
|
.font-monospace {
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-control,
|
||||||
|
.form-select {
|
||||||
|
background-color: var(--gm-panel-alt);
|
||||||
|
border-color: var(--gm-panel-border);
|
||||||
|
color: var(--gm-text);
|
||||||
|
}
|
||||||
|
.form-control:focus,
|
||||||
|
.form-select:focus {
|
||||||
|
background-color: var(--gm-panel-alt);
|
||||||
|
border-color: var(--gm-amber-deep);
|
||||||
|
color: var(--gm-text);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(255, 138, 30, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
.form-switch .form-check-input {
|
.form-switch .form-check-input {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 3rem;
|
width: 3rem;
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
|
background-color: #3a2e1c;
|
||||||
|
border-color: var(--gm-panel-border);
|
||||||
|
}
|
||||||
|
.form-switch .form-check-input:checked {
|
||||||
|
background-color: var(--gm-success);
|
||||||
|
border-color: var(--gm-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.power-switch #masterToggleLabel {
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--gm-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Modal ---------------- */
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--gm-panel);
|
||||||
|
border: 1px solid var(--gm-panel-border);
|
||||||
|
}
|
||||||
|
.modal-header,
|
||||||
|
.modal-footer {
|
||||||
|
border-color: var(--gm-panel-border);
|
||||||
|
}
|
||||||
|
#modalTitle {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--gm-amber);
|
||||||
|
}
|
||||||
.modal-lg {
|
.modal-lg {
|
||||||
max-width: 700px;
|
max-width: 700px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------- Tools tab ---------------- */
|
||||||
|
|
||||||
|
.tool-card h6 {
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--gm-amber);
|
||||||
|
border-bottom: 1px solid var(--gm-panel-border);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-results {
|
||||||
|
min-height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-scroll {
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- Advanced rule fields ---------------- */
|
||||||
|
|
||||||
|
#advancedSection > summary {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
color: var(--gm-amber);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#advancedSection .border {
|
||||||
|
border-color: var(--gm-panel-border) !important;
|
||||||
|
background: var(--gm-panel-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.replacement-row .form-control {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-list {
|
||||||
|
max-height: 450px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,17 +9,101 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container-fluid p-3">
|
<div class="container-fluid p-3">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="brand-row d-flex justify-content-between align-items-center mb-2">
|
||||||
<h4 class="mb-0">GreasyBoii</h4>
|
<div class="d-flex align-items-center gap-2">
|
||||||
<button class="btn btn-primary btn-sm" id="addRuleBtn">+ Add Rule</button>
|
<img src="icon48.png" alt="" class="brand-icon">
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0 brand-title">GreasyBoii</h4>
|
||||||
|
<div class="brand-tagline">🐒 grease-monkey grade page tweaks</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch mb-0 power-switch" title="Master enable/disable (Ctrl+Shift+G)">
|
||||||
|
<input class="form-check-input" type="checkbox" id="masterToggle" checked>
|
||||||
|
<label class="form-check-label small" for="masterToggle" id="masterToggleLabel">On</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="rulesList" class="rules-list mb-3">
|
<div class="hazard-strip mb-3"></div>
|
||||||
<!-- Rules will be dynamically inserted here -->
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||||
|
<ul class="nav nav-tabs" id="mainTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="rulesTabBtn" data-bs-toggle="tab" data-bs-target="#rulesPane" type="button" role="tab">Rules</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="toolsTabBtn" data-bs-toggle="tab" data-bs-target="#toolsPane" type="button" role="tab">Tools</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-outline-light" id="darkModeBtn" title="Add a dark-mode rule for this site">🌓</button>
|
||||||
|
<button class="btn btn-sm btn-outline-light" id="exportRulesBtn" title="Export rules to JSON">⬇ Export</button>
|
||||||
|
<button class="btn btn-sm btn-outline-light" id="importRulesBtn" title="Import rules from JSON">⬆ Import</button>
|
||||||
|
<input type="file" id="importFileInput" accept="application/json" hidden>
|
||||||
|
<button class="btn btn-primary btn-sm" id="addRuleBtn">+ Add Rule</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="emptyState" class="text-center text-muted py-4" style="display: none;">
|
<div class="tab-content">
|
||||||
<p>No rules yet. Click "Add Rule" to get started.</p>
|
<div class="tab-pane fade show active" id="rulesPane" role="tabpanel">
|
||||||
|
<div id="rulesList" class="rules-list mb-3">
|
||||||
|
<!-- Rules will be dynamically inserted here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="emptyState" class="text-center text-muted py-4" style="display: none;">
|
||||||
|
<p>No rules yet. Click "Add Rule" to get started.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-pane fade" id="toolsPane" role="tabpanel">
|
||||||
|
<div class="tool-card mb-3">
|
||||||
|
<h6>📥 Bulk Media Downloader</h6>
|
||||||
|
<div class="d-flex gap-2 mb-2">
|
||||||
|
<select class="form-select form-select-sm" id="mediaType" style="max-width: 150px;">
|
||||||
|
<option value="images">Images</option>
|
||||||
|
<option value="pdfs">PDFs</option>
|
||||||
|
<option value="videos">Videos</option>
|
||||||
|
<option value="custom">Custom extensions</option>
|
||||||
|
</select>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="mediaExtensions" placeholder="jpg,png,zip" style="display: none;">
|
||||||
|
<button class="btn btn-sm btn-outline-light" id="scanMediaBtn">Scan Page</button>
|
||||||
|
</div>
|
||||||
|
<div id="mediaResults" class="tool-results small text-muted">No scan yet.</div>
|
||||||
|
<button class="btn btn-sm btn-primary mt-2" id="downloadMediaBtn" disabled>Download All (0)</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-card mb-3">
|
||||||
|
<h6>🔗 Link Harvester</h6>
|
||||||
|
<div class="d-flex gap-2 mb-2">
|
||||||
|
<input type="text" class="form-control form-control-sm" id="linkContainerSelector" placeholder="Container selector (blank = whole page)">
|
||||||
|
<button class="btn btn-sm btn-outline-light" id="harvestLinksBtn">Extract</button>
|
||||||
|
</div>
|
||||||
|
<div id="linkResults" class="tool-results small text-muted">No links extracted yet.</div>
|
||||||
|
<div class="d-flex gap-2 mt-2">
|
||||||
|
<button class="btn btn-sm btn-primary" id="openLinksBtn" disabled>Open Selected in Tabs</button>
|
||||||
|
<button class="btn btn-sm btn-outline-light" id="exportLinksBtn" disabled>Export .txt</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tool-card mb-3">
|
||||||
|
<h6>🧮 Page Data Scraper</h6>
|
||||||
|
<div class="d-flex gap-2 mb-2">
|
||||||
|
<input type="text" class="form-control form-control-sm" id="scrapeSelector" placeholder="CSS selector, e.g. .price">
|
||||||
|
<select class="form-select form-select-sm" id="scrapeAttribute" style="max-width: 120px;">
|
||||||
|
<option value="text">Text</option>
|
||||||
|
<option value="href">href</option>
|
||||||
|
<option value="src">src</option>
|
||||||
|
<option value="custom">Attribute…</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<input type="text" class="form-control form-control-sm mb-2" id="scrapeCustomAttr" placeholder="attribute name" style="display: none;">
|
||||||
|
<button class="btn btn-sm btn-outline-light" id="scrapeBtn">Scrape</button>
|
||||||
|
<div id="scrapeResults" class="tool-results small text-muted mt-2">No data scraped yet.</div>
|
||||||
|
<div class="d-flex gap-2 mt-2">
|
||||||
|
<button class="btn btn-sm btn-primary" id="exportCsvBtn" disabled>Export CSV</button>
|
||||||
|
<button class="btn btn-sm btn-outline-light" id="exportJsonBtn" disabled>Export JSON</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -55,8 +139,11 @@
|
|||||||
|
|
||||||
<div class="mb-3" id="elementSelectorGroup" style="display: none;">
|
<div class="mb-3" id="elementSelectorGroup" style="display: none;">
|
||||||
<label for="elementSelector" class="form-label">Element Selector</label>
|
<label for="elementSelector" class="form-label">Element Selector</label>
|
||||||
<input type="text" class="form-control" id="elementSelector" placeholder="e.g., #my-element or .my-class">
|
<div class="input-group">
|
||||||
<small class="form-text text-muted">Enter a CSS selector</small>
|
<input type="text" class="form-control" id="elementSelector" placeholder="e.g., #my-element or .my-class">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" id="pickElementBtn" title="Pick element on the page">🎯 Pick</button>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted">Enter a CSS selector, or click Pick and select it on the page</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@@ -69,6 +156,35 @@
|
|||||||
<textarea class="form-control font-monospace" id="css" rows="6" placeholder="body { background-color: #000; }"></textarea>
|
<textarea class="form-control font-monospace" id="css" rows="6" placeholder="body { background-color: #000; }"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<details class="mb-3" id="advancedSection">
|
||||||
|
<summary class="form-label">🔧 Advanced (auto-click, text replace, SPA re-apply)</summary>
|
||||||
|
<div class="border rounded p-2 mt-2">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label for="autoClickSelector" class="form-label small">Auto-Click Selector</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="autoClickSelector" placeholder="e.g., .cookie-banner .accept">
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 mb-3">
|
||||||
|
<div class="flex-fill">
|
||||||
|
<label for="autoClickInterval" class="form-label small">Repeat every (sec, 0 = once)</label>
|
||||||
|
<input type="number" min="0" class="form-control form-control-sm" id="autoClickInterval" value="0">
|
||||||
|
</div>
|
||||||
|
<div class="flex-fill">
|
||||||
|
<label for="autoClickMax" class="form-label small">Max clicks (0 = unlimited)</label>
|
||||||
|
<input type="number" min="0" class="form-control form-control-sm" id="autoClickMax" value="0">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="form-label small">Text Replacements</label>
|
||||||
|
<div id="textReplacementsList" class="mb-2"></div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-light mb-3" id="addReplacementBtn">+ Add Replacement</button>
|
||||||
|
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="reapplyOnDomChanges">
|
||||||
|
<label class="form-check-label" for="reapplyOnDomChanges">Re-check on page changes (for SPAs)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" id="ruleEnabled" checked>
|
<input class="form-check-input" type="checkbox" id="ruleEnabled" checked>
|
||||||
<label class="form-check-label" for="ruleEnabled">
|
<label class="form-check-label" for="ruleEnabled">
|
||||||
|
|||||||
@@ -2,14 +2,15 @@
|
|||||||
let currentRules = [];
|
let currentRules = [];
|
||||||
let editingRuleIndex = -1;
|
let editingRuleIndex = -1;
|
||||||
let ruleModal;
|
let ruleModal;
|
||||||
|
let scannedMedia = [];
|
||||||
|
let harvestedLinks = [];
|
||||||
|
let scrapedData = [];
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Wait for Bootstrap to be fully loaded
|
|
||||||
if (typeof bootstrap !== 'undefined') {
|
if (typeof bootstrap !== 'undefined') {
|
||||||
initializeExtension();
|
initializeExtension();
|
||||||
} else {
|
} else {
|
||||||
// Fallback: wait a bit for Bootstrap to load
|
|
||||||
setTimeout(initializeExtension, 100);
|
setTimeout(initializeExtension, 100);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -20,21 +21,22 @@ function initializeExtension() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error initializing modal:', e);
|
console.error('Error initializing modal:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadRules();
|
loadRules();
|
||||||
setupEventListeners();
|
setupEventListeners();
|
||||||
|
setupAdvancedRuleFields();
|
||||||
|
setupHeaderControls();
|
||||||
|
setupToolsTab();
|
||||||
|
restorePendingPicker();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupEventListeners() {
|
function setupEventListeners() {
|
||||||
const addRuleBtn = document.getElementById('addRuleBtn');
|
const addRuleBtn = document.getElementById('addRuleBtn');
|
||||||
const saveRuleBtn = document.getElementById('saveRuleBtn');
|
const saveRuleBtn = document.getElementById('saveRuleBtn');
|
||||||
const matchTypeSelect = document.getElementById('matchType');
|
const matchTypeSelect = document.getElementById('matchType');
|
||||||
|
|
||||||
if (addRuleBtn) {
|
if (addRuleBtn) {
|
||||||
addRuleBtn.addEventListener('click', () => {
|
addRuleBtn.addEventListener('click', () => openRuleModal());
|
||||||
console.log('Add Rule button clicked');
|
|
||||||
openRuleModal();
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
console.error('Add Rule button not found!');
|
console.error('Add Rule button not found!');
|
||||||
}
|
}
|
||||||
@@ -96,7 +98,7 @@ function renderRules() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
emptyState.style.display = 'none';
|
emptyState.style.display = 'none';
|
||||||
|
|
||||||
rulesList.innerHTML = currentRules.map((rule, index) => {
|
rulesList.innerHTML = currentRules.map((rule, index) => {
|
||||||
const matchTypeLabel = {
|
const matchTypeLabel = {
|
||||||
'url': 'URL',
|
'url': 'URL',
|
||||||
@@ -106,19 +108,24 @@ function renderRules() {
|
|||||||
|
|
||||||
const hasJS = rule.javascript && rule.javascript.trim();
|
const hasJS = rule.javascript && rule.javascript.trim();
|
||||||
const hasCSS = rule.css && rule.css.trim();
|
const hasCSS = rule.css && rule.css.trim();
|
||||||
|
const hasAutoClick = rule.autoClickSelector && rule.autoClickSelector.trim();
|
||||||
|
const hasReplacements = rule.textReplacements && rule.textReplacements.length;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="rule-card" data-rule-index="${index}">
|
<div class="rule-card" data-rule-index="${index}">
|
||||||
<div class="rule-header">
|
<div class="rule-header">
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
<span class="rule-name">${escapeHtml(rule.name)}</span>
|
<span class="rule-name">${escapeHtml(rule.name)}</span>
|
||||||
<span class="badge rule-badge bg-secondary">${matchTypeLabel}</span>
|
<span class="badge rule-badge bg-secondary">${matchTypeLabel}</span>
|
||||||
${hasJS ? '<span class="badge rule-badge bg-warning">JS</span>' : ''}
|
${hasJS ? '<span class="badge rule-badge bg-warning">JS</span>' : ''}
|
||||||
${hasCSS ? '<span class="badge rule-badge bg-info">CSS</span>' : ''}
|
${hasCSS ? '<span class="badge rule-badge bg-info">CSS</span>' : ''}
|
||||||
|
${hasAutoClick ? '<span class="badge rule-badge bg-success">Click</span>' : ''}
|
||||||
|
${hasReplacements ? '<span class="badge rule-badge bg-primary">Replace</span>' : ''}
|
||||||
|
${rule.reapplyOnDomChanges ? '<span class="badge rule-badge bg-dark border">SPA</span>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="rule-actions">
|
<div class="rule-actions">
|
||||||
<div class="form-check form-switch mb-0">
|
<div class="form-check form-switch mb-0">
|
||||||
<input class="form-check-input rule-toggle" type="checkbox" ${rule.enabled ? 'checked' : ''}
|
<input class="form-check-input rule-toggle" type="checkbox" ${rule.enabled ? 'checked' : ''}
|
||||||
data-index="${index}" title="Enable/Disable">
|
data-index="${index}" title="Enable/Disable">
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm btn-outline-light btn-edit-rule" data-index="${index}" title="Edit">
|
<button class="btn btn-sm btn-outline-light btn-edit-rule" data-index="${index}" title="Edit">
|
||||||
@@ -141,19 +148,16 @@ function renderRules() {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
// Add event listeners using event delegation
|
|
||||||
attachRuleEventListeners();
|
attachRuleEventListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachRuleEventListeners() {
|
function attachRuleEventListeners() {
|
||||||
const rulesList = document.getElementById('rulesList');
|
const rulesList = document.getElementById('rulesList');
|
||||||
|
|
||||||
// Remove old listeners if any
|
|
||||||
rulesList.removeEventListener('click', handleRuleClick);
|
rulesList.removeEventListener('click', handleRuleClick);
|
||||||
rulesList.removeEventListener('change', handleRuleToggle);
|
rulesList.removeEventListener('change', handleRuleToggle);
|
||||||
|
|
||||||
// Add new listeners
|
|
||||||
rulesList.addEventListener('click', handleRuleClick);
|
rulesList.addEventListener('click', handleRuleClick);
|
||||||
rulesList.addEventListener('change', handleRuleToggle);
|
rulesList.addEventListener('change', handleRuleToggle);
|
||||||
}
|
}
|
||||||
@@ -161,20 +165,17 @@ function attachRuleEventListeners() {
|
|||||||
function handleRuleClick(e) {
|
function handleRuleClick(e) {
|
||||||
const editBtn = e.target.closest('.btn-edit-rule');
|
const editBtn = e.target.closest('.btn-edit-rule');
|
||||||
const deleteBtn = e.target.closest('.btn-delete-rule');
|
const deleteBtn = e.target.closest('.btn-delete-rule');
|
||||||
|
|
||||||
if (editBtn) {
|
if (editBtn) {
|
||||||
const index = parseInt(editBtn.dataset.index);
|
editRule(parseInt(editBtn.dataset.index));
|
||||||
editRule(index);
|
|
||||||
} else if (deleteBtn) {
|
} else if (deleteBtn) {
|
||||||
const index = parseInt(deleteBtn.dataset.index);
|
deleteRule(parseInt(deleteBtn.dataset.index));
|
||||||
deleteRule(index);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRuleToggle(e) {
|
function handleRuleToggle(e) {
|
||||||
if (e.target.classList.contains('rule-toggle')) {
|
if (e.target.classList.contains('rule-toggle')) {
|
||||||
const index = parseInt(e.target.dataset.index);
|
toggleRule(parseInt(e.target.dataset.index));
|
||||||
toggleRule(index);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +185,8 @@ function openRuleModal(ruleIndex = -1) {
|
|||||||
const form = document.getElementById('ruleForm');
|
const form = document.getElementById('ruleForm');
|
||||||
|
|
||||||
form.reset();
|
form.reset();
|
||||||
|
renderReplacementRows([]);
|
||||||
|
document.getElementById('advancedSection').open = false;
|
||||||
|
|
||||||
if (ruleIndex >= 0) {
|
if (ruleIndex >= 0) {
|
||||||
modalTitle.textContent = 'Edit Rule';
|
modalTitle.textContent = 'Edit Rule';
|
||||||
@@ -194,19 +197,29 @@ function openRuleModal(ruleIndex = -1) {
|
|||||||
document.getElementById('elementSelector').value = rule.elementSelector || '';
|
document.getElementById('elementSelector').value = rule.elementSelector || '';
|
||||||
document.getElementById('javascript').value = rule.javascript || '';
|
document.getElementById('javascript').value = rule.javascript || '';
|
||||||
document.getElementById('css').value = rule.css || '';
|
document.getElementById('css').value = rule.css || '';
|
||||||
|
document.getElementById('autoClickSelector').value = rule.autoClickSelector || '';
|
||||||
|
document.getElementById('autoClickInterval').value = rule.autoClickIntervalSec || 0;
|
||||||
|
document.getElementById('autoClickMax').value = rule.autoClickMax || 0;
|
||||||
|
document.getElementById('reapplyOnDomChanges').checked = !!rule.reapplyOnDomChanges;
|
||||||
document.getElementById('ruleEnabled').checked = rule.enabled;
|
document.getElementById('ruleEnabled').checked = rule.enabled;
|
||||||
|
renderReplacementRows(rule.textReplacements || []);
|
||||||
|
|
||||||
|
if (rule.autoClickSelector || (rule.textReplacements && rule.textReplacements.length) || rule.reapplyOnDomChanges) {
|
||||||
|
document.getElementById('advancedSection').open = true;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
modalTitle.textContent = 'Add Rule';
|
modalTitle.textContent = 'Add Rule';
|
||||||
document.getElementById('ruleEnabled').checked = true;
|
document.getElementById('ruleEnabled').checked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateFieldVisibility(document.getElementById('matchType').value);
|
updateFieldVisibility(document.getElementById('matchType').value);
|
||||||
|
showRuleModal();
|
||||||
// Show modal - ensure Bootstrap Modal is initialized
|
}
|
||||||
|
|
||||||
|
function showRuleModal() {
|
||||||
if (ruleModal) {
|
if (ruleModal) {
|
||||||
ruleModal.show();
|
ruleModal.show();
|
||||||
} else {
|
} else {
|
||||||
// Fallback: try to initialize and show
|
|
||||||
try {
|
try {
|
||||||
ruleModal = new bootstrap.Modal(document.getElementById('ruleModal'));
|
ruleModal = new bootstrap.Modal(document.getElementById('ruleModal'));
|
||||||
ruleModal.show();
|
ruleModal.show();
|
||||||
@@ -225,8 +238,12 @@ function saveRule() {
|
|||||||
const javascript = document.getElementById('javascript').value;
|
const javascript = document.getElementById('javascript').value;
|
||||||
const css = document.getElementById('css').value;
|
const css = document.getElementById('css').value;
|
||||||
const enabled = document.getElementById('ruleEnabled').checked;
|
const enabled = document.getElementById('ruleEnabled').checked;
|
||||||
|
const autoClickSelector = document.getElementById('autoClickSelector').value.trim();
|
||||||
|
const autoClickIntervalSec = parseInt(document.getElementById('autoClickInterval').value, 10) || 0;
|
||||||
|
const autoClickMax = parseInt(document.getElementById('autoClickMax').value, 10) || 0;
|
||||||
|
const reapplyOnDomChanges = document.getElementById('reapplyOnDomChanges').checked;
|
||||||
|
const textReplacements = collectReplacementRows().filter((r) => r.find.trim());
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
alert('Please enter a rule name');
|
alert('Please enter a rule name');
|
||||||
return;
|
return;
|
||||||
@@ -247,19 +264,14 @@ function saveRule() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!javascript && !css) {
|
if (!javascript && !css && !autoClickSelector && textReplacements.length === 0) {
|
||||||
alert('Please enter either JavaScript or CSS code');
|
alert('Please enter JavaScript, CSS, an auto-click selector, or a text replacement');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rule = {
|
const rule = {
|
||||||
name,
|
name, matchType, urlPattern, elementSelector, javascript, css, enabled,
|
||||||
matchType,
|
autoClickSelector, autoClickIntervalSec, autoClickMax, reapplyOnDomChanges, textReplacements
|
||||||
urlPattern,
|
|
||||||
elementSelector,
|
|
||||||
javascript,
|
|
||||||
css,
|
|
||||||
enabled
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingRuleIndex >= 0) {
|
if (editingRuleIndex >= 0) {
|
||||||
@@ -296,7 +308,385 @@ function toggleRule(index) {
|
|||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = text == null ? '' : text;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Advanced rule fields: text replacement rows + the "pick on page" button
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function setupAdvancedRuleFields() {
|
||||||
|
document.getElementById('addReplacementBtn').addEventListener('click', () => {
|
||||||
|
const rows = collectReplacementRows();
|
||||||
|
rows.push({ find: '', replace: '' });
|
||||||
|
renderReplacementRows(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('textReplacementsList').addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('.remove-replacement-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
const rows = collectReplacementRows();
|
||||||
|
const index = parseInt(btn.closest('.replacement-row').dataset.index, 10);
|
||||||
|
rows.splice(index, 1);
|
||||||
|
renderReplacementRows(rows);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('pickElementBtn').addEventListener('click', async () => {
|
||||||
|
const tab = await getActiveTab();
|
||||||
|
if (!tab || !tab.id) return;
|
||||||
|
|
||||||
|
await browser.storage.local.set({ pickerPending: collectModalSnapshot() });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await browser.tabs.sendMessage(tab.id, { action: 'startPicker' });
|
||||||
|
} catch (e) {
|
||||||
|
alert('Could not start the element picker on this page (it may be a restricted browser page).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The popup closes as soon as focus moves to the page, so there's nothing more
|
||||||
|
// to do here — the picked selector is relayed back through storage and restored
|
||||||
|
// the next time this popup opens (see restorePendingPicker()).
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReplacementRows(rows) {
|
||||||
|
const list = document.getElementById('textReplacementsList');
|
||||||
|
list.innerHTML = rows.map((r, i) => `
|
||||||
|
<div class="input-group input-group-sm mb-1 replacement-row" data-index="${i}">
|
||||||
|
<input type="text" class="form-control replacement-find" placeholder="Find" value="${escapeHtml(r.find || '')}">
|
||||||
|
<input type="text" class="form-control replacement-replace" placeholder="Replace with" value="${escapeHtml(r.replace || '')}">
|
||||||
|
<button class="btn btn-outline-danger remove-replacement-btn" type="button" title="Remove">✕</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectReplacementRows() {
|
||||||
|
return [...document.querySelectorAll('.replacement-row')].map((row) => ({
|
||||||
|
find: row.querySelector('.replacement-find').value,
|
||||||
|
replace: row.querySelector('.replacement-replace').value
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectModalSnapshot() {
|
||||||
|
return {
|
||||||
|
modalTitle: document.getElementById('modalTitle').textContent,
|
||||||
|
editingRuleIndex,
|
||||||
|
ruleName: document.getElementById('ruleName').value,
|
||||||
|
matchType: document.getElementById('matchType').value,
|
||||||
|
urlPattern: document.getElementById('urlPattern').value,
|
||||||
|
elementSelector: document.getElementById('elementSelector').value,
|
||||||
|
javascript: document.getElementById('javascript').value,
|
||||||
|
css: document.getElementById('css').value,
|
||||||
|
autoClickSelector: document.getElementById('autoClickSelector').value,
|
||||||
|
autoClickInterval: document.getElementById('autoClickInterval').value,
|
||||||
|
autoClickMax: document.getElementById('autoClickMax').value,
|
||||||
|
reapplyOnDomChanges: document.getElementById('reapplyOnDomChanges').checked,
|
||||||
|
textReplacements: collectReplacementRows(),
|
||||||
|
ruleEnabled: document.getElementById('ruleEnabled').checked
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreModalState(snapshot, pickedSelector) {
|
||||||
|
editingRuleIndex = snapshot.editingRuleIndex;
|
||||||
|
document.getElementById('modalTitle').textContent = snapshot.modalTitle;
|
||||||
|
document.getElementById('ruleName').value = snapshot.ruleName;
|
||||||
|
document.getElementById('matchType').value = snapshot.matchType;
|
||||||
|
document.getElementById('urlPattern').value = snapshot.urlPattern;
|
||||||
|
document.getElementById('elementSelector').value = pickedSelector || snapshot.elementSelector;
|
||||||
|
document.getElementById('javascript').value = snapshot.javascript;
|
||||||
|
document.getElementById('css').value = snapshot.css;
|
||||||
|
document.getElementById('autoClickSelector').value = snapshot.autoClickSelector;
|
||||||
|
document.getElementById('autoClickInterval').value = snapshot.autoClickInterval;
|
||||||
|
document.getElementById('autoClickMax').value = snapshot.autoClickMax;
|
||||||
|
document.getElementById('reapplyOnDomChanges').checked = snapshot.reapplyOnDomChanges;
|
||||||
|
document.getElementById('ruleEnabled').checked = snapshot.ruleEnabled;
|
||||||
|
renderReplacementRows(snapshot.textReplacements || []);
|
||||||
|
document.getElementById('advancedSection').open = true;
|
||||||
|
updateFieldVisibility(snapshot.matchType);
|
||||||
|
showRuleModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function restorePendingPicker() {
|
||||||
|
browser.storage.local.get(['pickerPending', 'pendingPickResult']).then((result) => {
|
||||||
|
if (!result.pickerPending) return;
|
||||||
|
restoreModalState(result.pickerPending, result.pendingPickResult || null);
|
||||||
|
browser.storage.local.remove(['pickerPending', 'pendingPickResult']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Header controls: master toggle, dark mode quick action, import/export
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function setupHeaderControls() {
|
||||||
|
const masterToggle = document.getElementById('masterToggle');
|
||||||
|
const masterToggleLabel = document.getElementById('masterToggleLabel');
|
||||||
|
|
||||||
|
browser.storage.sync.get(['masterEnabled']).then((result) => {
|
||||||
|
const enabled = result.masterEnabled !== false;
|
||||||
|
masterToggle.checked = enabled;
|
||||||
|
masterToggleLabel.textContent = enabled ? 'On' : 'Off';
|
||||||
|
});
|
||||||
|
|
||||||
|
masterToggle.addEventListener('change', () => {
|
||||||
|
browser.storage.sync.set({ masterEnabled: masterToggle.checked }).then(() => {
|
||||||
|
masterToggleLabel.textContent = masterToggle.checked ? 'On' : 'Off';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('darkModeBtn').addEventListener('click', addDarkModeRule);
|
||||||
|
document.getElementById('exportRulesBtn').addEventListener('click', exportRules);
|
||||||
|
document.getElementById('importRulesBtn').addEventListener('click', () => {
|
||||||
|
document.getElementById('importFileInput').click();
|
||||||
|
});
|
||||||
|
document.getElementById('importFileInput').addEventListener('change', importRules);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addDarkModeRule() {
|
||||||
|
const tab = await getActiveTab();
|
||||||
|
if (!tab || !tab.url) return;
|
||||||
|
|
||||||
|
let hostname;
|
||||||
|
try {
|
||||||
|
hostname = new URL(tab.url).hostname;
|
||||||
|
} catch (e) {
|
||||||
|
alert('Cannot create a dark mode rule for this page.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hostname) {
|
||||||
|
alert('Cannot create a dark mode rule for this page.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rule = {
|
||||||
|
name: `Dark Mode: ${hostname}`,
|
||||||
|
matchType: 'url',
|
||||||
|
urlPattern: hostname,
|
||||||
|
elementSelector: '',
|
||||||
|
javascript: '',
|
||||||
|
css: 'html { filter: invert(1) hue-rotate(180deg); }\n' +
|
||||||
|
'img, video, picture, canvas, svg, [style*="background-image"] { filter: invert(1) hue-rotate(180deg); }',
|
||||||
|
enabled: true,
|
||||||
|
autoClickSelector: '',
|
||||||
|
autoClickIntervalSec: 0,
|
||||||
|
autoClickMax: 0,
|
||||||
|
reapplyOnDomChanges: false,
|
||||||
|
textReplacements: []
|
||||||
|
};
|
||||||
|
|
||||||
|
currentRules.push(rule);
|
||||||
|
browser.storage.sync.set({ rules: currentRules }).then(() => {
|
||||||
|
loadRules();
|
||||||
|
alert(`Added a dark mode rule for ${hostname}. Reload the page to see it — edit or disable it any time from the Rules tab.`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportRules() {
|
||||||
|
downloadBlob(JSON.stringify(currentRules, null, 2), 'greasyboii-rules.json', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function importRules() {
|
||||||
|
const input = document.getElementById('importFileInput');
|
||||||
|
const file = input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
let imported;
|
||||||
|
try {
|
||||||
|
imported = JSON.parse(reader.result);
|
||||||
|
} catch (e) {
|
||||||
|
alert('That file is not valid JSON.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const looksLikeRules = Array.isArray(imported) &&
|
||||||
|
imported.every((r) => r && typeof r.name === 'string' && typeof r.matchType === 'string');
|
||||||
|
if (!looksLikeRules) {
|
||||||
|
alert('That file does not look like a GreasyBoii rules export.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Import ${imported.length} rule(s) from this file?`)) return;
|
||||||
|
const replace = confirm('Replace all existing rules with the imported ones?\n\nOK = Replace\nCancel = Add to existing rules');
|
||||||
|
currentRules = replace ? imported : currentRules.concat(imported);
|
||||||
|
|
||||||
|
browser.storage.sync.set({ rules: currentRules }).then(loadRules);
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Tools tab: bulk media downloader, link harvester, page data scraper
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function setupToolsTab() {
|
||||||
|
setupMediaDownloader();
|
||||||
|
setupLinkHarvester();
|
||||||
|
setupDataScraper();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupMediaDownloader() {
|
||||||
|
const mediaType = document.getElementById('mediaType');
|
||||||
|
const mediaExtensions = document.getElementById('mediaExtensions');
|
||||||
|
const scanMediaBtn = document.getElementById('scanMediaBtn');
|
||||||
|
const mediaResults = document.getElementById('mediaResults');
|
||||||
|
const downloadMediaBtn = document.getElementById('downloadMediaBtn');
|
||||||
|
|
||||||
|
mediaType.addEventListener('change', () => {
|
||||||
|
mediaExtensions.style.display = mediaType.value === 'custom' ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
scanMediaBtn.addEventListener('click', async () => {
|
||||||
|
const tab = await getActiveTab();
|
||||||
|
if (!tab) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await browser.tabs.sendMessage(tab.id, {
|
||||||
|
action: 'scanMedia',
|
||||||
|
mediaType: mediaType.value,
|
||||||
|
extensions: mediaExtensions.value
|
||||||
|
});
|
||||||
|
scannedMedia = (response && response.items) || [];
|
||||||
|
mediaResults.textContent = `${scannedMedia.length} file(s) found.`;
|
||||||
|
downloadMediaBtn.disabled = scannedMedia.length === 0;
|
||||||
|
downloadMediaBtn.textContent = `Download All (${scannedMedia.length})`;
|
||||||
|
} catch (e) {
|
||||||
|
mediaResults.textContent = 'Could not scan this page (try reloading it first).';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
downloadMediaBtn.addEventListener('click', () => {
|
||||||
|
scannedMedia.forEach((item, i) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
browser.downloads.download({ url: item.url, filename: item.filename, conflictAction: 'uniquify' })
|
||||||
|
.catch((e) => console.error('[GreasyBoii] Download failed for', item.url, e));
|
||||||
|
}, i * 150);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupLinkHarvester() {
|
||||||
|
const containerSelector = document.getElementById('linkContainerSelector');
|
||||||
|
const harvestLinksBtn = document.getElementById('harvestLinksBtn');
|
||||||
|
const linkResults = document.getElementById('linkResults');
|
||||||
|
const openLinksBtn = document.getElementById('openLinksBtn');
|
||||||
|
const exportLinksBtn = document.getElementById('exportLinksBtn');
|
||||||
|
|
||||||
|
harvestLinksBtn.addEventListener('click', async () => {
|
||||||
|
const tab = await getActiveTab();
|
||||||
|
if (!tab) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await browser.tabs.sendMessage(tab.id, {
|
||||||
|
action: 'harvestLinks',
|
||||||
|
containerSelector: containerSelector.value.trim()
|
||||||
|
});
|
||||||
|
harvestedLinks = (response && response.items) || [];
|
||||||
|
renderLinkResults();
|
||||||
|
} catch (e) {
|
||||||
|
linkResults.textContent = 'Could not read links on this page (try reloading it first).';
|
||||||
|
openLinksBtn.disabled = true;
|
||||||
|
exportLinksBtn.disabled = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderLinkResults() {
|
||||||
|
if (harvestedLinks.length === 0) {
|
||||||
|
linkResults.textContent = 'No links found.';
|
||||||
|
openLinksBtn.disabled = true;
|
||||||
|
exportLinksBtn.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
linkResults.innerHTML = `<div class="tool-scroll">${harvestedLinks.map((link, i) => `
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input link-check" type="checkbox" id="link-${i}" data-index="${i}" checked>
|
||||||
|
<label class="form-check-label text-truncate d-block" for="link-${i}" title="${escapeHtml(link.url)}">${escapeHtml(link.text || link.url)}</label>
|
||||||
|
</div>
|
||||||
|
`).join('')}</div><div class="text-muted mt-1">${harvestedLinks.length} link(s) found</div>`;
|
||||||
|
openLinksBtn.disabled = false;
|
||||||
|
exportLinksBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCheckedLinks() {
|
||||||
|
const checkedIndexes = [...document.querySelectorAll('.link-check:checked')].map((el) => parseInt(el.dataset.index, 10));
|
||||||
|
return harvestedLinks.filter((_, i) => checkedIndexes.includes(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
openLinksBtn.addEventListener('click', () => {
|
||||||
|
getCheckedLinks().forEach((link) => browser.tabs.create({ url: link.url, active: false }));
|
||||||
|
});
|
||||||
|
|
||||||
|
exportLinksBtn.addEventListener('click', () => {
|
||||||
|
const text = getCheckedLinks().map((l) => l.url).join('\n');
|
||||||
|
downloadBlob(text, 'greasyboii-links.txt', 'text/plain');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupDataScraper() {
|
||||||
|
const selector = document.getElementById('scrapeSelector');
|
||||||
|
const attribute = document.getElementById('scrapeAttribute');
|
||||||
|
const customAttr = document.getElementById('scrapeCustomAttr');
|
||||||
|
const scrapeBtn = document.getElementById('scrapeBtn');
|
||||||
|
const scrapeResults = document.getElementById('scrapeResults');
|
||||||
|
const exportCsvBtn = document.getElementById('exportCsvBtn');
|
||||||
|
const exportJsonBtn = document.getElementById('exportJsonBtn');
|
||||||
|
|
||||||
|
attribute.addEventListener('change', () => {
|
||||||
|
customAttr.style.display = attribute.value === 'custom' ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
scrapeBtn.addEventListener('click', async () => {
|
||||||
|
const tab = await getActiveTab();
|
||||||
|
if (!tab || !selector.value.trim()) return;
|
||||||
|
|
||||||
|
const attr = attribute.value === 'custom' ? customAttr.value.trim() : attribute.value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await browser.tabs.sendMessage(tab.id, {
|
||||||
|
action: 'scrapeData',
|
||||||
|
selector: selector.value.trim(),
|
||||||
|
attribute: attr
|
||||||
|
});
|
||||||
|
scrapedData = (response && response.items) || [];
|
||||||
|
scrapeResults.textContent = `${scrapedData.length} value(s) scraped.`;
|
||||||
|
exportCsvBtn.disabled = scrapedData.length === 0;
|
||||||
|
exportJsonBtn.disabled = scrapedData.length === 0;
|
||||||
|
} catch (e) {
|
||||||
|
scrapeResults.textContent = 'Could not scrape this page (try reloading it first).';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
exportCsvBtn.addEventListener('click', () => {
|
||||||
|
const csv = scrapedData.map((v) => `"${String(v).replace(/"/g, '""')}"`).join('\n');
|
||||||
|
downloadBlob(csv, 'greasyboii-scrape.csv', 'text/csv');
|
||||||
|
});
|
||||||
|
|
||||||
|
exportJsonBtn.addEventListener('click', () => {
|
||||||
|
downloadBlob(JSON.stringify(scrapedData, null, 2), 'greasyboii-scrape.json', 'application/json');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Shared helpers
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function getActiveTab() {
|
||||||
|
const tabs = await browser.tabs.query({ active: true, currentWindow: true });
|
||||||
|
return tabs[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBlob(content, filename, mimeType) {
|
||||||
|
const blob = new Blob([content], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
echo "📦 Packaging GreasyBoii v1.0.4..."
|
# Packages both extensions into dist/. Each browser has its own release script that
|
||||||
mkdir -p dist
|
# reads its version straight out of its manifest.json — nothing is hardcoded here.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
echo "Chrome..."
|
./package-chrome-release.sh
|
||||||
cd chrome && zip -r ../dist/greasyboii-chrome-v1.0.4.zip * -x "*.DS_Store" && cd ..
|
./package-firefox-release.sh
|
||||||
|
|
||||||
echo "Firefox..."
|
|
||||||
cd firefox && zip -r ../dist/greasyboii-firefox-v1.0.4.zip * -x "*.DS_Store" && cd ..
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ Done! Packages created:"
|
echo "✅ Done! Packages created:"
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Builds a store-ready GreasyBoii Chrome package: dist/greasyboii-chrome-v<version>.zip,
|
||||||
|
# with manifest.json at the zip root — ready to upload to the Chrome Web Store dashboard.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
VERSION=$(python3 -c "import json; print(json.load(open('chrome/manifest.json'))['version'])")
|
||||||
|
|
||||||
|
REQUIRED_FILES=(
|
||||||
|
manifest.json background.js content.js
|
||||||
|
popup.html popup.css popup.js
|
||||||
|
bootstrap.min.css bootstrap.bundle.min.js
|
||||||
|
icon16.png icon48.png icon128.png
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "Checking required files..."
|
||||||
|
for f in "${REQUIRED_FILES[@]}"; do
|
||||||
|
if [ ! -f "chrome/$f" ]; then
|
||||||
|
echo "Missing chrome/$f" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
mkdir -p dist
|
||||||
|
OUT="dist/greasyboii-chrome-v${VERSION}.zip"
|
||||||
|
rm -f "$OUT"
|
||||||
|
|
||||||
|
echo "Packaging GreasyBoii Chrome v${VERSION}..."
|
||||||
|
(cd chrome && zip -rq "../$OUT" . -x "*.DS_Store" -x "icon.svg")
|
||||||
|
|
||||||
|
echo "Done: $OUT"
|
||||||
|
unzip -l "$OUT"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Builds a store-ready GreasyBoii Firefox package: dist/greasyboii-firefox-v<version>.zip,
|
||||||
|
# with manifest.json at the zip root — ready for AMO upload or `web-ext sign`.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
VERSION=$(python3 -c "import json; print(json.load(open('firefox/manifest.json'))['version'])")
|
||||||
|
|
||||||
|
REQUIRED_FILES=(
|
||||||
|
manifest.json background.js content.js
|
||||||
|
popup.html popup.css popup.js
|
||||||
|
bootstrap.min.css bootstrap.bundle.min.js
|
||||||
|
icon16.png icon48.png icon128.png
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "Checking required files..."
|
||||||
|
for f in "${REQUIRED_FILES[@]}"; do
|
||||||
|
if [ ! -f "firefox/$f" ]; then
|
||||||
|
echo "Missing firefox/$f" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
mkdir -p dist
|
||||||
|
OUT="dist/greasyboii-firefox-v${VERSION}.zip"
|
||||||
|
rm -f "$OUT"
|
||||||
|
|
||||||
|
echo "Packaging GreasyBoii Firefox v${VERSION}..."
|
||||||
|
(cd firefox && zip -rq "../$OUT" . -x "*.DS_Store" -x "icon.svg")
|
||||||
|
|
||||||
|
echo "Done: $OUT"
|
||||||
|
unzip -l "$OUT"
|
||||||