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
+38 -1
View File
@@ -1,6 +1,7 @@
// Background service worker for Chrome extension
chrome.runtime.onInstalled.addListener(() => {
console.log('GreasyBoii extension installed');
refreshBadge();
});
// Listen for messages from content script
@@ -11,7 +12,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
});
return true; // Keep channel open for async response
}
// Execute JavaScript via scripting API to bypass CSP
if (request.action === 'executeScript') {
if (!sender.tab || !sender.tab.id) {
@@ -48,4 +49,40 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
return true; // Keep channel open for async response
}
// 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' });
});
}
+325 -70
View File
@@ -1,93 +1,348 @@
// Content script that runs on all pages
(function() {
(function () {
'use strict';
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') {
document.addEventListener('DOMContentLoaded', initGreasyBoii);
document.addEventListener('DOMContentLoaded', boot);
} 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() {
console.log('[GreasyBoii] Initializing...');
// Get rules from storage and execute matching ones
chrome.storage.sync.get(['rules'], (result) => {
chrome.storage.sync.get(['rules']).then((result) => {
const rules = result.rules || [];
const currentUrl = window.location.href;
console.log('[GreasyBoii] Found', rules.length, 'rule(s) in storage');
rules.forEach((rule) => {
if (!rule.enabled) {
console.log('[GreasyBoii] Skipping disabled rule:', rule.name);
return;
}
rules.forEach(evaluateRule);
let shouldExecute = false;
// 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`);
}
});
if (rules.some((r) => r.enabled && r.reapplyOnDomChanges)) {
watchForChanges(rules.filter((r) => r.enabled && r.reapplyOnDomChanges));
}
});
}
function executeRule(rule) {
// Execute JavaScript using chrome.scripting API to bypass CSP
if (rule.javascript && rule.javascript.trim()) {
console.log(`[GreasyBoii] Sending JavaScript execution request for rule: ${rule.name}`);
// Send message to background script to execute JavaScript
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?.error);
}
});
function ruleMatches(rule) {
const currentUrl = window.location.href;
if (rule.matchType === 'url') {
return currentUrl.includes(rule.urlPattern);
} else if (rule.matchType === 'element') {
return !!document.querySelector(rule.elementSelector);
} else if (rule.matchType === 'both') {
return currentUrl.includes(rule.urlPattern) && !!document.querySelector(rule.elementSelector);
}
return false;
}
// Execute CSS - CSS injection doesn't have CSP issues
if (rule.css && rule.css.trim()) {
try {
console.log(`[GreasyBoii] Injecting CSS for rule: ${rule.name}`);
const style = document.createElement('style');
style.textContent = rule.css;
style.setAttribute('data-greasyboii-rule', rule.name);
(document.head || document.documentElement).appendChild(style);
console.log(`[GreasyBoii] ✓ CSS injected successfully`);
} catch (error) {
console.error(`[GreasyBoii] Error applying CSS for rule "${rule.name}":`, error);
}
function evaluateRule(rule) {
if (!rule.enabled) return;
const matched = ruleMatches(rule);
const wasMatched = ruleMatchState.get(rule.name) === true;
ruleMatchState.set(rule.name, matched);
if (matched && !wasMatched) {
console.log(`[GreasyBoii] ✓ Executing rule: ${rule.name}`);
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,
"name": "GreasyBoii",
"version": "1.0.4",
"description": "Execute custom JavaScript and CSS based on URL patterns and page elements",
"version": "2.0.0",
"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",
"permissions": [
"storage",
"activeTab",
"scripting"
"scripting",
"downloads"
],
"host_permissions": [
"<all_urls>"
@@ -15,6 +16,15 @@
"background": {
"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": [
{
"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 {
width: 600px;
min-height: 400px;
max-height: 600px;
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 {
max-height: 450px;
overflow-y: auto;
::-webkit-scrollbar {
width: 8px;
}
::-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 {
border: 1px solid #495057;
border-radius: 0.375rem;
/* ---------------- Brand header ---------------- */
.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;
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 {
border-color: #6c757d;
border-color: var(--gm-amber-deep);
}
.rule-header {
@@ -30,8 +158,9 @@ body {
}
.rule-name {
font-weight: 600;
font-size: 1.1rem;
font-weight: 700;
font-size: 1.05rem;
color: var(--gm-text);
}
.rule-actions {
@@ -41,25 +170,152 @@ body {
.rule-details {
font-size: 0.875rem;
color: #adb5bd;
color: var(--gm-text-muted);
}
.rule-badge {
font-size: 0.75rem;
font-size: 0.7rem;
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-family: 'Courier New', monospace;
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 {
cursor: pointer;
width: 3rem;
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 {
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>
<body>
<div class="container-fluid p-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">GreasyBoii</h4>
<button class="btn btn-primary btn-sm" id="addRuleBtn">+ Add Rule</button>
<div class="brand-row d-flex justify-content-between align-items-center mb-2">
<div class="d-flex align-items-center gap-2">
<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 id="rulesList" class="rules-list mb-3">
<!-- Rules will be dynamically inserted here -->
<div class="hazard-strip mb-3"></div>
<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 id="emptyState" class="text-center text-muted py-4" style="display: none;">
<p>No rules yet. Click "Add Rule" to get started.</p>
<div class="tab-content">
<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>
@@ -55,8 +139,11 @@
<div class="mb-3" id="elementSelectorGroup" style="display: none;">
<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">
<small class="form-text text-muted">Enter a CSS selector</small>
<div class="input-group">
<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 class="mb-3">
@@ -69,6 +156,35 @@
<textarea class="form-control font-monospace" id="css" rows="6" placeholder="body { background-color: #000; }"></textarea>
</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">
<input class="form-check-input" type="checkbox" id="ruleEnabled" checked>
<label class="form-check-label" for="ruleEnabled">
+433 -43
View File
@@ -1,15 +1,16 @@
// Popup script for managing rules
// Popup script for managing rules (Chrome version)
let currentRules = [];
let editingRuleIndex = -1;
let ruleModal;
let scannedMedia = [];
let harvestedLinks = [];
let scrapedData = [];
// Initialize
document.addEventListener('DOMContentLoaded', () => {
// Wait for Bootstrap to be fully loaded
if (typeof bootstrap !== 'undefined') {
initializeExtension();
} else {
// Fallback: wait a bit for Bootstrap to load
setTimeout(initializeExtension, 100);
}
});
@@ -20,21 +21,22 @@ function initializeExtension() {
} catch (e) {
console.error('Error initializing modal:', e);
}
loadRules();
setupEventListeners();
setupAdvancedRuleFields();
setupHeaderControls();
setupToolsTab();
restorePendingPicker();
}
function setupEventListeners() {
const addRuleBtn = document.getElementById('addRuleBtn');
const saveRuleBtn = document.getElementById('saveRuleBtn');
const matchTypeSelect = document.getElementById('matchType');
if (addRuleBtn) {
addRuleBtn.addEventListener('click', () => {
console.log('Add Rule button clicked');
openRuleModal();
});
addRuleBtn.addEventListener('click', () => openRuleModal());
} else {
console.error('Add Rule button not found!');
}
@@ -79,7 +81,7 @@ function updateFieldVisibility(matchType) {
}
function loadRules() {
chrome.storage.sync.get(['rules'], (result) => {
chrome.storage.sync.get(['rules']).then((result) => {
currentRules = result.rules || [];
renderRules();
});
@@ -96,7 +98,7 @@ function renderRules() {
}
emptyState.style.display = 'none';
rulesList.innerHTML = currentRules.map((rule, index) => {
const matchTypeLabel = {
'url': 'URL',
@@ -106,19 +108,24 @@ function renderRules() {
const hasJS = rule.javascript && rule.javascript.trim();
const hasCSS = rule.css && rule.css.trim();
const hasAutoClick = rule.autoClickSelector && rule.autoClickSelector.trim();
const hasReplacements = rule.textReplacements && rule.textReplacements.length;
return `
<div class="rule-card" data-rule-index="${index}">
<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="badge rule-badge bg-secondary">${matchTypeLabel}</span>
${hasJS ? '<span class="badge rule-badge bg-warning">JS</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 class="rule-actions">
<div class="form-check form-switch mb-0">
<input class="form-check-input rule-toggle" type="checkbox" ${rule.enabled ? 'checked' : ''}
<input class="form-check-input rule-toggle" type="checkbox" ${rule.enabled ? 'checked' : ''}
data-index="${index}" title="Enable/Disable">
</div>
<button class="btn btn-sm btn-outline-light btn-edit-rule" data-index="${index}" title="Edit">
@@ -141,19 +148,16 @@ function renderRules() {
</div>
`;
}).join('');
// Add event listeners using event delegation
attachRuleEventListeners();
}
function attachRuleEventListeners() {
const rulesList = document.getElementById('rulesList');
// Remove old listeners if any
rulesList.removeEventListener('click', handleRuleClick);
rulesList.removeEventListener('change', handleRuleToggle);
// Add new listeners
rulesList.addEventListener('click', handleRuleClick);
rulesList.addEventListener('change', handleRuleToggle);
}
@@ -161,20 +165,17 @@ function attachRuleEventListeners() {
function handleRuleClick(e) {
const editBtn = e.target.closest('.btn-edit-rule');
const deleteBtn = e.target.closest('.btn-delete-rule');
if (editBtn) {
const index = parseInt(editBtn.dataset.index);
editRule(index);
editRule(parseInt(editBtn.dataset.index));
} else if (deleteBtn) {
const index = parseInt(deleteBtn.dataset.index);
deleteRule(index);
deleteRule(parseInt(deleteBtn.dataset.index));
}
}
function handleRuleToggle(e) {
if (e.target.classList.contains('rule-toggle')) {
const index = parseInt(e.target.dataset.index);
toggleRule(index);
toggleRule(parseInt(e.target.dataset.index));
}
}
@@ -184,6 +185,8 @@ function openRuleModal(ruleIndex = -1) {
const form = document.getElementById('ruleForm');
form.reset();
renderReplacementRows([]);
document.getElementById('advancedSection').open = false;
if (ruleIndex >= 0) {
modalTitle.textContent = 'Edit Rule';
@@ -194,19 +197,29 @@ function openRuleModal(ruleIndex = -1) {
document.getElementById('elementSelector').value = rule.elementSelector || '';
document.getElementById('javascript').value = rule.javascript || '';
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;
renderReplacementRows(rule.textReplacements || []);
if (rule.autoClickSelector || (rule.textReplacements && rule.textReplacements.length) || rule.reapplyOnDomChanges) {
document.getElementById('advancedSection').open = true;
}
} else {
modalTitle.textContent = 'Add Rule';
document.getElementById('ruleEnabled').checked = true;
}
updateFieldVisibility(document.getElementById('matchType').value);
// Show modal - ensure Bootstrap Modal is initialized
showRuleModal();
}
function showRuleModal() {
if (ruleModal) {
ruleModal.show();
} else {
// Fallback: try to initialize and show
try {
ruleModal = new bootstrap.Modal(document.getElementById('ruleModal'));
ruleModal.show();
@@ -225,8 +238,12 @@ function saveRule() {
const javascript = document.getElementById('javascript').value;
const css = document.getElementById('css').value;
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) {
alert('Please enter a rule name');
return;
@@ -247,19 +264,14 @@ function saveRule() {
return;
}
if (!javascript && !css) {
alert('Please enter either JavaScript or CSS code');
if (!javascript && !css && !autoClickSelector && textReplacements.length === 0) {
alert('Please enter JavaScript, CSS, an auto-click selector, or a text replacement');
return;
}
const rule = {
name,
matchType,
urlPattern,
elementSelector,
javascript,
css,
enabled
name, matchType, urlPattern, elementSelector, javascript, css, enabled,
autoClickSelector, autoClickIntervalSec, autoClickMax, reapplyOnDomChanges, textReplacements
};
if (editingRuleIndex >= 0) {
@@ -268,7 +280,7 @@ function saveRule() {
currentRules.push(rule);
}
chrome.storage.sync.set({ rules: currentRules }, () => {
chrome.storage.sync.set({ rules: currentRules }).then(() => {
loadRules();
ruleModal.hide();
});
@@ -281,7 +293,7 @@ function editRule(index) {
function deleteRule(index) {
if (confirm(`Delete rule "${currentRules[index].name}"?`)) {
currentRules.splice(index, 1);
chrome.storage.sync.set({ rules: currentRules }, () => {
chrome.storage.sync.set({ rules: currentRules }).then(() => {
loadRules();
});
}
@@ -289,14 +301,392 @@ function deleteRule(index) {
function toggleRule(index) {
currentRules[index].enabled = !currentRules[index].enabled;
chrome.storage.sync.set({ rules: currentRules }, () => {
chrome.storage.sync.set({ rules: currentRules }).then(() => {
loadRules();
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
div.textContent = text == null ? '' : text;
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);
}