Version 2 improvements with UI and functionality

This commit is contained in:
Andy Dixon
2026-09-02 09:43:28 +01:00
parent e7d2200ef0
commit db49ef78f4
26 changed files with 2750 additions and 314 deletions
+28
View File
@@ -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
+106
View File
@@ -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.
+98 -15
View File
@@ -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
+37
View File
@@ -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
@@ -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' });
});
}
+322 -67
View File
@@ -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 style = document.createElement('style'); const matched = ruleMatches(rule);
style.textContent = rule.css; const wasMatched = ruleMatchState.get(rule.name) === true;
style.setAttribute('data-greasyboii-rule', rule.name); ruleMatchState.set(rule.name, matched);
(document.head || document.documentElement).appendChild(style);
console.log(`[GreasyBoii] ✓ CSS injected successfully`); if (matched && !wasMatched) {
} catch (error) { console.log(`[GreasyBoii] ✓ Executing rule: ${rule.name}`);
console.error(`[GreasyBoii] Error applying CSS for rule "${rule.name}":`, error); executeRule(rule);
}
} }
} }
// 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(' > ');
}
})(); })();
+15
View File
@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 B

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 B

After

Width:  |  Height:  |  Size: 527 B

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 B

After

Width:  |  Height:  |  Size: 1.4 KiB

+13 -3
View File
@@ -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>"],
+268 -12
View File
@@ -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;
}
+125 -9
View File
@@ -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">
+424 -34
View File
@@ -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);
} }
}); });
@@ -23,6 +24,10 @@ function initializeExtension() {
loadRules(); loadRules();
setupEventListeners(); setupEventListeners();
setupAdvancedRuleFields();
setupHeaderControls();
setupToolsTab();
restorePendingPicker();
} }
function setupEventListeners() { function setupEventListeners() {
@@ -31,10 +36,7 @@ function setupEventListeners() {
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();
}); });
@@ -106,15 +108,20 @@ 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">
@@ -142,18 +149,15 @@ function renderRules() {
`; `;
}).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);
} }
@@ -163,18 +167,15 @@ function handleRuleClick(e) {
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);
}
+37
View File
@@ -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' });
});
}
+343 -90
View File
@@ -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 style = document.createElement('style'); const matched = ruleMatches(rule);
style.textContent = rule.css; const wasMatched = ruleMatchState.get(rule.name) === true;
style.setAttribute('data-greasyboii-rule', rule.name); ruleMatchState.set(rule.name, matched);
(document.head || document.documentElement).appendChild(style);
console.log(`[GreasyBoii] ✓ CSS injected successfully`); if (matched && !wasMatched) {
} catch (error) { console.log(`[GreasyBoii] ✓ Executing rule: ${rule.name}`);
console.error(`[GreasyBoii] Error applying CSS for rule "${rule.name}":`, error); executeRule(rule);
}
} }
} }
// 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(' > ');
}
})(); })();
+15
View File
@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 B

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 B

After

Width:  |  Height:  |  Size: 527 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 B

After

Width:  |  Height:  |  Size: 1.4 KiB

+12 -2
View File
@@ -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>"],
+268 -12
View File
@@ -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;
}
+125 -9
View File
@@ -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">
+419 -29
View File
@@ -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);
} }
}); });
@@ -23,6 +24,10 @@ function initializeExtension() {
loadRules(); loadRules();
setupEventListeners(); setupEventListeners();
setupAdvancedRuleFields();
setupHeaderControls();
setupToolsTab();
restorePendingPicker();
} }
function setupEventListeners() { function setupEventListeners() {
@@ -31,10 +36,7 @@ function setupEventListeners() {
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!');
} }
@@ -106,15 +108,20 @@ 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">
@@ -142,18 +149,15 @@ function renderRules() {
`; `;
}).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);
} }
@@ -163,18 +167,15 @@ function handleRuleClick(e) {
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);
}
+6 -7
View File
@@ -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:"
+32
View File
@@ -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"
+32
View File
@@ -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"