Save imgs from booru directly to Eagle cool (single-worker queue, draggable panel)
// ==UserScript==
// @name Booru → Eagle cool Saver
// @namespace booru-eagle
// @version 1.5 Final
// @license MIT
// @description Save imgs from booru directly to Eagle cool (single-worker queue, draggable panel)
// @match https://rule34.xxx/*
// @match https://danbooru.donmai.us/*
// @match https://chan.sankakucomplex.com/*
// @match https://gelbooru.com/*
// @match https://konachan.com/*
// @match https://eagle.cool/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_addValueChangeListener
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// ==/UserScript==
(function () {
"use strict";
const KEYS = {
API_TOKEN: "eagle_api_token",
API_PORT: "eagle_api_port",
SETTINGS_DISMISSED: "eagle_settings_dismissed",
PARENT_ID: "eagle_parent_id",
QUEUE: "eagle_queue_v5",
WORKER: "eagle_worker_v5",
PANEL_STATE: "eagle_panel_v3",
AUTO_CLOSE: "eagle_autoclose_v3",
UI_STATE: "eagle_ui_state_v2",
PROGRESS: "eagle_progress_v2",
};
const CONFIG = {
DEFAULT_PORT: "41595",
DEFAULT_TOKEN: "Your Eagle API token here",
WORKER_TIMEOUT: 25000,
WORKER_INTERVAL: 1500,
HEARTBEAT_INTERVAL: 5000,
WORKER_ELECTION_MIN_DELAY: 200,
WORKER_ELECTION_MAX_DELAY: 800,
WORKER_RECOVERY_INTERVAL: 5000,
ENQUEUE_MAX_RETRIES: 5,
ENQUEUE_BASE_DELAY: 50,
ENQUEUE_JITTER: 50,
DEBOUNCE_DELAY: 150,
DEBOUNCE_RESET_DELAY: 100,
PROGRESS_INCREMENT: 5,
PROGRESS_INCREMENT_INTERVAL: 50,
PROGRESS_MAX_SHOW: 90,
PROGRESS_HIDE_DELAY: 400,
AUTOCLOSE_FALLBACK_TIMEOUT: 300000,
AUTOCLOSE_CLOSE_DELAY: 500,
PANEL_CHECK_INTERVAL: 1000,
TOAST_DISPLAY_TIME: 2500,
TOAST_FADE_DURATION: 200,
SYNC_APPLY_DELAY: 50,
QUEUE_PROCESS_DELAY: 500,
};
const TAB_ID = generateTabId();
const DOM = {
panel: null,
header: null,
body: null,
saveBtn: null,
parentBtn: null,
stopBtn: null,
autoCloseBtn: null,
resInfo: null,
queueInfo: null,
workerBadge: null,
collapseBtn: null,
progressOuter: null,
progressInner: null,
};
const worker = {
isWorker: false,
timer: null,
heartbeatTimer: null,
processing: false,
currentPostId: null,
};
const ui = {
collapsed: false,
autoCloseEnabled: false,
panelSyncing: false,
progressSyncing: false,
uiStateSyncing: false,
_applyingQueueUpdate: false,
_applyingWorkerUpdate: false,
_applyingUIState: false,
_applyingProgress: false,
_applyingPanelState: false,
_closingTab: false,
};
const debounceTimers = {
uiState: null,
panelState: null,
progress: null,
};
const autoClose = {
pendingPostId: null,
timer: null,
FALLBACK_TIMEOUT: CONFIG.AUTOCLOSE_FALLBACK_TIMEOUT,
};
const progress = {
interval: null,
};
function generateTabId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2, 6);
}
function log(level, ...args) {
const prefix = `[Eagle ${TAB_ID}]`;
if (level === 'error') console.error(prefix, ...args);
else if (level === 'warn') console.warn(prefix, ...args);
else console.log(prefix, ...args);
}
function getQueue() {
try {
const raw = GM_getValue(KEYS.QUEUE, null);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
log('error', 'Queue parse error:', e);
return [];
}
}
function saveQueue(queue) {
try {
GM_setValue(KEYS.QUEUE, JSON.stringify(queue));
} catch (e) {
log('error', 'Queue save error:', e);
}
}
async function enqueueItem(url, tags, postId) {
if (!postId || !url) {
log('warn', 'enqueueItem: missing postId or url');
return false;
}
const maxRetries = CONFIG.ENQUEUE_MAX_RETRIES;
const baseDelay = CONFIG.ENQUEUE_BASE_DELAY;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
const queue = getQueue();
if (queue.some(item => item.postId === postId)) {
log('warn', `Post ${postId} already in queue`);
toast("⚠ Already in queue");
return false;
}
queue.push({
postId,
url,
tags,
timestamp: Date.now(),
addedByTab: TAB_ID,
});
saveQueue(queue);
const verify = getQueue();
if (verify.some(item => item.postId === postId)) {
log('info', `Enqueued ${postId}, queue length: ${verify.length}`);
return true;
}
const delay = baseDelay * Math.pow(2, attempt - 1) + Math.random() * CONFIG.ENQUEUE_JITTER;
log('warn', `Queue conflict on ${postId}, attempt ${attempt}/${maxRetries}, retrying in ${Math.round(delay)}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
}
log('error', `Failed to enqueue ${postId} after ${maxRetries} attempts`);
toast("⚠ Failed to add to queue — too many conflicts");
return false;
}
function dequeueItem(postId) {
const queue = getQueue();
const idx = queue.findIndex(item => item.postId === postId);
if (idx === -1) return false;
queue.splice(idx, 1);
saveQueue(queue);
const verify = getQueue();
if (!verify.some(item => item.postId === postId)) {
log('info', `Dequeued ${postId}, queue length: ${verify.length}`);
return true;
}
log('warn', `Dequeue verification failed for ${postId}`);
return false;
}
function clearQueue() {
const queue = getQueue();
const wasProcessing = worker.processing;
const currentPostId = worker.currentPostId;
saveQueue([]);
if (worker.processing) {
worker.processing = false;
worker.currentPostId = null;
log('warn', `Cancelled processing of ${currentPostId}`);
}
showProgressBar(false);
updateQueueUI();
const count = queue.length;
if (count > 0 || wasProcessing) {
const text = wasProcessing
? `Cleared ${count} items + cancelled ${currentPostId}`
: `Cleared ${count} items from queue`;
log('info', text);
toast(`✓ ${text}`);
} else {
toast("Queue already empty");
}
}
function getActiveWorker() {
try {
const w = GM_getValue(KEYS.WORKER, null);
if (!w) return null;
const age = Date.now() - w.heartbeat;
if (age > CONFIG.WORKER_TIMEOUT) {
log('warn', `Worker ${w.tabId} expired (${age}ms ago)`);
return null;
}
return w;
} catch (e) {
log('error', 'getActiveWorker error:', e);
return null;
}
}
function becomeWorker() {
const active = getActiveWorker();
if (active && active.tabId !== TAB_ID) {
log('info', `Cannot become worker — ${active.tabId} is active`);
return false;
}
worker.isWorker = true;
saveWorkerState();
startHeartbeat();
log('info', 'Became WORKER');
updateWorkerIndicator();
return true;
}
function resignWorker() {
worker.isWorker = false;
worker.processing = false;
worker.currentPostId = null;
stopHeartbeat();
stopWorkerLoop();
const active = getActiveWorker();
if (active && active.tabId === TAB_ID) {
GM_setValue(KEYS.WORKER, null);
}
log('info', 'Resigned as worker');
updateWorkerIndicator();
}
function saveWorkerState() {
GM_setValue(KEYS.WORKER, {
tabId: TAB_ID,
heartbeat: Date.now(),
});
}
function heartbeat() {
const active = getActiveWorker();
if (active && active.tabId === TAB_ID) {
saveWorkerState();
} else {
if (worker.isWorker) {
log('warn', 'Lost worker status, resigning');
resignWorker();
}
}
}
function startHeartbeat() {
stopHeartbeat();
worker.heartbeatTimer = setInterval(heartbeat, CONFIG.HEARTBEAT_INTERVAL);
}
function stopHeartbeat() {
if (worker.heartbeatTimer) {
clearInterval(worker.heartbeatTimer);
worker.heartbeatTimer = null;
}
}
function startWorkerLoop() {
stopWorkerLoop();
worker.timer = setInterval(() => {
if (!worker.isWorker) {
stopWorkerLoop();
return;
}
const queue = getQueue();
if (queue.length === 0) {
resignWorker();
updateQueueUI();
return;
}
heartbeat();
processNextItem();
}, CONFIG.WORKER_INTERVAL);
setTimeout(() => {
if (worker.isWorker) {
heartbeat();
processNextItem();
}
}, CONFIG.WORKER_ELECTION_MIN_DELAY);
}
function stopWorkerLoop() {
if (worker.timer) {
clearInterval(worker.timer);
worker.timer = null;
}
}
function tryBecomeWorker() {
if (worker.isWorker) return;
const active = getActiveWorker();
if (!active) {
const randomDelay = CONFIG.WORKER_ELECTION_MIN_DELAY + Math.random() * (CONFIG.WORKER_ELECTION_MAX_DELAY - CONFIG.WORKER_ELECTION_MIN_DELAY);
log('info', `Attempting to become worker in ${Math.round(randomDelay)}ms`);
setTimeout(() => {
const stillNoActive = getActiveWorker();
if (!stillNoActive && !worker.isWorker) {
if (becomeWorker()) {
startWorkerLoop();
}
}
}, randomDelay);
}
}
async function processNextItem() {
if (!worker.isWorker || worker.processing) return;
const queue = getQueue();
if (queue.length === 0) {
updateQueueUI();
return;
}
const item = queue[0];
worker.processing = true;
worker.currentPostId = item.postId;
log('info', `Processing ${item.postId} (${item.url.substring(0, 50)}...)`);
updateQueueUI();
saveProgressState(true, 0);
try {
const success = await sendToEagle(item.url, item.tags);
if (success) {
dequeueItem(item.postId);
saveProgressState(false, 100);
} else {
log('warn', `Failed to send ${item.postId}, keeping in queue`);
saveProgressState(false, 0);
}
} catch (err) {
log('error', `Error processing ${item.postId}:`, err);
saveProgressState(false, 0);
} finally {
worker.processing = false;
worker.currentPostId = null;
updateQueueUI();
checkAutoClose();
}
}
function waitForAutoClose(postId) {
if (autoClose.timer) {
clearTimeout(autoClose.timer);
autoClose.timer = null;
}
autoClose.pendingPostId = postId;
toast("✓ Queued — will close after upload");
autoClose.timer = setTimeout(() => {
if (autoClose.pendingPostId === postId) {
log('warn', `Auto-close fallback for ${postId}, closing tab`);
autoClose.pendingPostId = null;
autoClose.timer = null;
closeTab();
}
}, autoClose.FALLBACK_TIMEOUT);
}
function checkAutoClose() {
if (!autoClose.pendingPostId) return;
const queue = getQueue();
const stillInQueue = queue.some(item => item.postId === autoClose.pendingPostId);
const stillProcessing = worker.currentPostId === autoClose.pendingPostId;
if (!stillInQueue && !stillProcessing) {
log('info', `Auto-close: ${autoClose.pendingPostId} processed, closing tab`);
toast("✓ Done — closing tab");
if (autoClose.timer) {
clearTimeout(autoClose.timer);
autoClose.timer = null;
}
autoClose.pendingPostId = null;
setTimeout(() => closeTab(), CONFIG.AUTOCLOSE_CLOSE_DELAY);
}
}
function closeTab() {
if (ui._closingTab) {
log('warn', 'closeTab called while already closing — ignoring');
return;
}
ui._closingTab = true;
autoClose.pendingPostId = null;
if (autoClose.timer) {
clearTimeout(autoClose.timer);
autoClose.timer = null;
}
log('info', `Attempting to close tab (history.length=${history.length})`);
if (history.length > 1) {
log('info', 'Navigating back (history.back)');
history.back();
} else {
try {
const closed = window.close();
if (closed || window.closed) {
log('info', 'Tab closed via window.close()');
return;
}
} catch (e) {
log('warn', 'window.close() threw:', e);
}
try {
location.replace('about:blank');
} catch (e2) {
log('error', 'All close methods failed');
}
}
setTimeout(() => {
ui._closingTab = false;
}, 2000);
}
function saveUIState() {
if (!DOM.panel || ui._applyingUIState) return;
if (debounceTimers.uiState) {
clearTimeout(debounceTimers.uiState);
}
debounceTimers.uiState = setTimeout(() => {
const state = {
autoClose: ui.autoCloseEnabled,
parentID: GM_getValue(KEYS.PARENT_ID, null),
tabId: TAB_ID,
timestamp: Date.now(),
};
ui.uiStateSyncing = true;
GM_setValue(KEYS.UI_STATE, JSON.stringify(state));
setTimeout(() => { ui.uiStateSyncing = false; }, CONFIG.DEBOUNCE_RESET_DELAY);
debounceTimers.uiState = null;
}, CONFIG.DEBOUNCE_DELAY);
}
function applyUIState(state) {
if (!state || !DOM.panel || ui._applyingUIState) return;
ui._applyingUIState = true;
try {
if (state.autoClose !== undefined && state.autoClose !== ui.autoCloseEnabled) {
ui.autoCloseEnabled = state.autoClose;
updateAutoCloseBtn();
if (DOM.saveBtn) {
DOM.saveBtn.textContent = ui.autoCloseEnabled ? "Save+Close" : "Save";
DOM.saveBtn.title = ui.autoCloseEnabled
? "Alt+Z — add to queue & close tab"
: "Alt+Z — add to queue";
}
}
updatePostPageUI();
if (DOM.resInfo) {
const onPost = isPostPage();
const resolution = getImageResolution();
DOM.resInfo.textContent = onPost ? resolution : "[not a post page]";
}
if (state.parentID !== undefined) {
updateColors();
}
} finally {
setTimeout(() => { ui._applyingUIState = false; }, CONFIG.SYNC_APPLY_DELAY);
}
}
function saveProgressState(show, width) {
if (ui._applyingProgress) return;
if (debounceTimers.progress) {
clearTimeout(debounceTimers.progress);
}
debounceTimers.progress = setTimeout(() => {
ui.progressSyncing = true;
GM_setValue(KEYS.PROGRESS, JSON.stringify({
show,
width,
tabId: TAB_ID,
timestamp: Date.now()
}));
setTimeout(() => { ui.progressSyncing = false; }, CONFIG.DEBOUNCE_RESET_DELAY);
debounceTimers.progress = null;
}, CONFIG.DEBOUNCE_DELAY);
}
function applyProgressState(state) {
if (!state || !DOM.progressOuter || !DOM.progressInner || ui._applyingProgress) return;
ui._applyingProgress = true;
try {
if (state.show) {
DOM.progressOuter.style.display = "block";
DOM.progressInner.style.width = (state.width || 0) + "%";
} else {
DOM.progressOuter.style.display = "none";
DOM.progressInner.style.width = "0%";
}
} finally {
setTimeout(() => { ui._applyingProgress = false; }, CONFIG.SYNC_APPLY_DELAY);
}
}
function savePanelState() {
if (!DOM.panel || ui._applyingPanelState) return;
if (debounceTimers.panelState) {
clearTimeout(debounceTimers.panelState);
}
debounceTimers.panelState = setTimeout(() => {
const state = {
left: DOM.panel.style.left,
top: DOM.panel.style.top,
transform: DOM.panel.style.transform,
collapsed: ui.collapsed,
};
ui.panelSyncing = true;
GM_setValue(KEYS.PANEL_STATE, JSON.stringify(state));
setTimeout(() => { ui.panelSyncing = false; }, CONFIG.DEBOUNCE_RESET_DELAY);
debounceTimers.panelState = null;
}, CONFIG.DEBOUNCE_DELAY);
}
function applyPanelState(state) {
if (!state || !DOM.panel || ui._applyingPanelState) return;
ui._applyingPanelState = true;
try {
DOM.panel.style.left = state.left;
DOM.panel.style.top = state.top;
DOM.panel.style.transform = state.transform;
if (state.collapsed !== undefined && state.collapsed !== ui.collapsed) {
setCollapsed(state.collapsed);
}
} finally {
setTimeout(() => { ui._applyingPanelState = false; }, CONFIG.SYNC_APPLY_DELAY);
}
}
function setupValueChangeListeners() {
GM_addValueChangeListener(KEYS.QUEUE, (name, oldVal, newVal) => {
if (ui._applyingQueueUpdate) return;
ui._applyingQueueUpdate = true;
try {
updateQueueUI();
checkAutoClose();
if (worker.isWorker && !worker.processing) {
setTimeout(() => processNextItem(), CONFIG.QUEUE_PROCESS_DELAY);
}
} finally {
setTimeout(() => { ui._applyingQueueUpdate = false; }, CONFIG.DEBOUNCE_RESET_DELAY);
}
});
GM_addValueChangeListener(KEYS.WORKER, (name, oldVal, newVal) => {
if (ui._applyingWorkerUpdate) return;
ui._applyingWorkerUpdate = true;
try {
if (!worker.isWorker) {
const randomDelay = 1000 + Math.random() * 1000;
setTimeout(() => {
const active = getActiveWorker();
if (!active && getQueue().length > 0) {
tryBecomeWorker();
}
}, randomDelay);
}
} finally {
setTimeout(() => { ui._applyingWorkerUpdate = false; }, CONFIG.DEBOUNCE_RESET_DELAY);
}
});
GM_addValueChangeListener(KEYS.UI_STATE, (name, oldVal, newVal) => {
if (ui.uiStateSyncing || ui._applyingUIState) return;
try {
if (newVal) {
ui._applyingUIState = true;
applyUIState(JSON.parse(newVal));
}
} catch (e) {
log('warn', 'UI_STATE parse error:', e);
}
});
GM_addValueChangeListener(KEYS.PROGRESS, (name, oldVal, newVal) => {
if (ui.progressSyncing || ui._applyingProgress) return;
try {
if (newVal) {
ui._applyingProgress = true;
applyProgressState(JSON.parse(newVal));
}
} catch (e) {
log('warn', 'PROGRESS parse error:', e);
}
});
GM_addValueChangeListener(KEYS.PANEL_STATE, (name, oldVal, newVal) => {
if (ui.panelSyncing || ui._applyingPanelState) return;
try {
if (newVal) {
ui._applyingPanelState = true;
applyPanelState(JSON.parse(newVal));
}
} catch (e) {
log('warn', 'PANEL_STATE parse error:', e);
}
});
}
function startWorkerRecoveryCheck() {
setInterval(() => {
if (!worker.isWorker) {
const active = getActiveWorker();
const queue = getQueue();
if (!active && queue.length > 0) {
log('info', 'No active worker found in recovery check, trying to become worker');
tryBecomeWorker();
}
}
}, CONFIG.WORKER_RECOVERY_INTERVAL);
}
function getApiUrl() {
const port = GM_getValue(KEYS.API_PORT, CONFIG.DEFAULT_PORT);
const token = GM_getValue(KEYS.API_TOKEN, null);
if (!token) {
log('warn', 'API token not configured');
return null;
}
return `http://localhost:${port}/api/item/addFromURL?token=${token}`;
}
async function sendToEagle(url, tags) {
if (!url) {
log('warn', 'sendToEagle: empty URL');
toast("✗ No URL");
return false;
}
const apiUrl = getApiUrl();
if (!apiUrl) {
log('error', 'sendToEagle: API not configured');
toast("⚠️ No API token! (Alt+S to configure)");
return false;
}
showProgressBar(true);
let finalUrl = url;
if (isSankaku() && !url.startsWith('data:')) {
try {
finalUrl = await toDataURL(url);
} catch (err) {
log('error', 'Sankaku URL conversion failed:', err);
toast("⚠ Image conversion failed, using original URL");
}
}
const payload = JSON.stringify({
url: finalUrl,
name: document.title,
website: location.href,
tags: tags,
headers: { referer: location.href }
});
log('info', `Sending to Eagle: ${finalUrl.substring(0, 60)}...`);
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: "POST",
url: apiUrl,
headers: { "Content-Type": "application/json" },
data: payload,
timeout: 60000,
onload: function(response) {
showProgressBar(false);
try {
const data = JSON.parse(response.responseText);
if (data.status === "success") {
log('info', 'Successfully saved to Eagle');
toast("✓ Saved to Eagle");
resolve(true);
} else {
log('error', 'Eagle API error:', data);
toast("✗ Eagle: " + (data.message || "Unknown error"));
resolve(false);
}
} catch (e) {
log('error', 'Response parse error:', e,
'Response:', response.responseText?.substring(0, 200));
toast("✗ Response parse error");
resolve(false);
}
},
onerror: function(err) {
showProgressBar(false);
log('error', 'Network error:', err);
toast("✗ Network error — is Eagle running?");
resolve(false);
},
ontimeout: function() {
showProgressBar(false);
log('warn', 'Request timeout (60s)');
toast("✗ Timeout — Eagle may be busy");
resolve(false);
}
});
});
}
async function toDataURL(url) {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: "GET",
url: url,
responseType: "blob",
timeout: 60000,
onload: function(response) {
const blob = response.response;
if (!blob) {
log('error', 'toDataURL: empty response');
resolve(url);
return;
}
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = () => {
log('error', 'toDataURL: FileReader error');
resolve(url);
};
reader.readAsDataURL(blob);
},
onerror: function(err) {
log('error', 'toDataURL: network error', err);
resolve(url);
},
ontimeout: function() {
log('warn', 'toDataURL: timeout');
resolve(url);
}
});
});
}
function toast(text) {
if (!document.body) return;
const d = document.createElement("div");
d.textContent = text;
d.style.cssText = `
position:fixed;top:20px;right:20px;
background:rgba(18,18,24,0.92);
backdrop-filter:blur(12px);
color:#e8e8e8;
padding:9px 16px;
border-radius:10px;
z-index:1000000;
font-size:13px;
font-family:'Segoe UI',system-ui,sans-serif;
font-weight:500;
box-shadow:0 6px 20px rgba(0,0,0,0.4);
border:1px solid rgba(255,255,255,0.06);
opacity:0;
transform:translateY(-8px);
transition:opacity 0.2s ease,transform 0.2s ease;
`;
document.body.appendChild(d);
requestAnimationFrame(() => {
d.style.opacity = "1";
d.style.transform = "translateY(0)";
});
setTimeout(() => {
d.style.opacity = "0";
d.style.transform = "translateY(-8px)";
setTimeout(() => d.remove(), CONFIG.TOAST_FADE_DURATION);
}, CONFIG.TOAST_DISPLAY_TIME);
}
function createPanel() {
if (DOM.panel) return;
DOM.panel = document.createElement("div");
DOM.panel.id = "eagle-panel";
DOM.panel.style.cssText = `
position: fixed;
top: 50px;
left: 50%;
transform: translateX(-50%);
min-width: 280px;
z-index: 999999;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
background: rgba(18, 18, 24, 0.88);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.04) inset;
overflow: hidden;
transition: box-shadow 0.3s ease;
user-select: none;
`;
DOM.header = document.createElement("div");
DOM.header.style.cssText = `
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
cursor: grab;
border-bottom: 1px solid rgba(255,255,255,0.06);
background: rgba(255,255,255,0.02);
`;
DOM.header.onmouseenter = () => {
DOM.panel.style.boxShadow = "0 16px 48px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.06) inset";
};
DOM.header.onmouseleave = () => {
DOM.panel.style.boxShadow = "0 12px 40px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.04) inset";
};
const title = document.createElement("span");
title.style.cssText = "color: #e8e8e8; font-size: 13px; font-weight: 600; letter-spacing: 0.3px;";
title.textContent = "Eagle Saver";
DOM.workerBadge = document.createElement("span");
DOM.workerBadge.style.cssText = "font-size: 12px; line-height: 1; transition: all 0.3s ease;";
DOM.collapseBtn = document.createElement("button");
DOM.collapseBtn.style.cssText = `
background: none;
border: none;
color: #888;
cursor: pointer;
font-size: 16px;
padding: 2px 6px;
border-radius: 6px;
line-height: 1;
transition: all 0.2s ease;
transform: rotate(180deg);
`;
DOM.collapseBtn.textContent = "▾";
DOM.collapseBtn.onmouseenter = () => {
DOM.collapseBtn.style.color = "#fff";
DOM.collapseBtn.style.background = "rgba(255,255,255,0.08)";
};
DOM.collapseBtn.onmouseleave = () => {
DOM.collapseBtn.style.color = "#888";
DOM.collapseBtn.style.background = "none";
};
DOM.collapseBtn.onclick = (e) => {
e.stopPropagation();
toggleCollapse();
};
DOM.header.appendChild(title);
DOM.header.appendChild(DOM.workerBadge);
DOM.header.appendChild(DOM.collapseBtn);
DOM.panel.appendChild(DOM.header);
DOM.body = document.createElement("div");
DOM.body.style.cssText = `
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 10px 14px 12px;
max-height: 500px;
opacity: 1;
pointer-events: auto;
transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s ease;
`;
const btnBox = document.createElement("div");
btnBox.style.cssText = "display:flex;gap:6px;flex-wrap:wrap;justify-content:center;";
DOM.saveBtn = document.createElement("button");
DOM.saveBtn.textContent = "Save";
DOM.saveBtn.title = "Alt+Z — add to queue";
DOM.parentBtn = document.createElement("button");
DOM.parentBtn.textContent = "Parent";
DOM.parentBtn.title = "Alt+X — set parent ID & save";
DOM.stopBtn = document.createElement("button");
DOM.stopBtn.textContent = "Stop";
DOM.stopBtn.title = "Alt+C — clear parent, stop worker & clear queue";
DOM.autoCloseBtn = document.createElement("button");
DOM.autoCloseBtn.textContent = "⏻";
DOM.autoCloseBtn.title = "Auto-close tab after Save";
const baseBtnStyle = "padding:7px 14px;border:none;border-radius:8px;cursor:pointer;font-size:12px;font-weight:600;letter-spacing:0.2px;transition:all 0.15s ease;";
DOM.saveBtn.style.cssText = baseBtnStyle + "background:rgba(255,152,0,0.85);color:#fff;";
DOM.parentBtn.style.cssText = baseBtnStyle + "background:rgba(255,152,0,0.85);color:#fff;";
DOM.stopBtn.style.cssText = baseBtnStyle + "background:rgba(244,67,54,0.85);color:#fff;";
DOM.autoCloseBtn.style.cssText = baseBtnStyle + "background:rgba(96,125,139,0.6);color:#90a4ae;font-size:16px;padding:7px 10px;";
DOM.autoCloseBtn.style.minWidth = "36px";
DOM.autoCloseBtn.style.textAlign = "center";
[DOM.saveBtn, DOM.parentBtn, DOM.stopBtn].forEach(b => {
b.onmouseenter = () => {
b.style.transform = "translateY(-1px)";
b.style.filter = "brightness(1.15)";
b.style.boxShadow = "0 4px 12px rgba(0,0,0,0.3)";
};
b.onmouseleave = () => {
b.style.transform = "none";
b.style.filter = "none";
b.style.boxShadow = "none";
};
b.onmousedown = () => {
b.style.transform = "translateY(0) scale(0.97)";
};
b.onmouseup = () => {
b.style.transform = "translateY(-1px)";
};
});
DOM.autoCloseBtn.onmouseenter = () => {
DOM.autoCloseBtn.style.transform = "translateY(-1px)";
DOM.autoCloseBtn.style.filter = "brightness(1.3)";
};
DOM.autoCloseBtn.onmouseleave = () => {
if (!ui.autoCloseEnabled) {
DOM.autoCloseBtn.style.transform = "none";
DOM.autoCloseBtn.style.filter = "none";
}
};
btnBox.appendChild(DOM.saveBtn);
btnBox.appendChild(DOM.parentBtn);
btnBox.appendChild(DOM.stopBtn);
btnBox.appendChild(DOM.autoCloseBtn);
DOM.body.appendChild(btnBox);
DOM.resInfo = document.createElement("div");
DOM.resInfo.style.cssText = "color:#4fc3f7;font-size:11px;font-weight:600;letter-spacing:0.3px;";
DOM.resInfo.textContent = "[...]";
DOM.body.appendChild(DOM.resInfo);
DOM.queueInfo = document.createElement("div");
DOM.queueInfo.style.cssText = "color:#ffeb3b;font-size:11px;font-weight:600;display:none;letter-spacing:0.3px;";
DOM.body.appendChild(DOM.queueInfo);
DOM.progressOuter = document.createElement("div");
DOM.progressOuter.id = "eagle-progress";
DOM.progressOuter.style.cssText = `
width: 100%;
height: 4px;
background: rgba(255,255,255,0.08);
border-radius: 2px;
overflow: hidden;
display: none;
`;
DOM.progressInner = document.createElement("div");
DOM.progressInner.id = "eagle-progress-inner";
DOM.progressInner.style.cssText = `
width: 0%;
height: 100%;
background: linear-gradient(90deg, #4caf50, #66bb6a);
border-radius: 2px;
transition: width 0.1s linear;
`;
DOM.progressOuter.appendChild(DOM.progressInner);
DOM.body.appendChild(DOM.progressOuter);
DOM.panel.appendChild(DOM.body);
setupDragAndDrop();
document.body.appendChild(DOM.panel);
try {
const savedState = GM_getValue(KEYS.PANEL_STATE, null);
if (savedState) applyPanelState(JSON.parse(savedState));
} catch (e) {}
ui.autoCloseEnabled = GM_getValue(KEYS.AUTO_CLOSE, false);
updateAutoCloseBtn();
if (ui.autoCloseEnabled && DOM.saveBtn) {
DOM.saveBtn.textContent = "Save+Close";
DOM.saveBtn.title = "Alt+Z — add to queue & close tab";
}
updatePostPageUI();
updateColors();
}
function setupDragAndDrop() {
let isDragging = false;
let dragStartX = 0, dragStartY = 0;
let panelStartLeft = 0, panelStartTop = 0;
DOM.header.onmousedown = (e) => {
if (e.target === DOM.collapseBtn) return;
isDragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
panelStartLeft = DOM.panel.offsetLeft;
panelStartTop = DOM.panel.offsetTop;
DOM.panel.style.transform = "none";
DOM.header.style.cursor = "grabbing";
e.preventDefault();
e.stopPropagation();
};
document.addEventListener("mousemove", (e) => {
if (!isDragging) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
const newLeft = panelStartLeft + dx;
const newTop = panelStartTop + dy;
const panelRect = DOM.panel.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
DOM.panel.style.left = Math.max(0, Math.min(newLeft, vw - panelRect.width)) + "px";
DOM.panel.style.top = Math.max(0, Math.min(newTop, vh - panelRect.height)) + "px";
});
document.addEventListener("mouseup", () => {
if (isDragging) {
isDragging = false;
DOM.header.style.cursor = "";
savePanelState();
}
});
DOM.header.addEventListener("selectstart", (e) => e.preventDefault());
}
function updateColors() {
const parentID = GM_getValue(KEYS.PARENT_ID, null);
if (parentID) {
DOM.saveBtn.style.background = "rgba(76,175,80,0.85)";
DOM.parentBtn.style.background = "rgba(76,175,80,0.85)";
DOM.parentBtn.textContent = "Parent: " + parentID;
} else {
DOM.saveBtn.style.background = "rgba(255,152,0,0.85)";
DOM.parentBtn.style.background = "rgba(255,152,0,0.85)";
DOM.parentBtn.textContent = "Parent";
}
if (DOM.resInfo) {
DOM.resInfo.textContent = getImageResolution();
}
updateQueueUI();
updateWorkerIndicator();
saveUIState();
}
function updatePostPageUI() {
const onPost = isPostPage();
const opacity = onPost ? "1" : "0.35";
const pointer = onPost ? "auto" : "none";
if (DOM.saveBtn) {
DOM.saveBtn.style.opacity = opacity;
DOM.saveBtn.style.pointerEvents = pointer;
}
if (DOM.parentBtn) {
DOM.parentBtn.style.opacity = opacity;
DOM.parentBtn.style.pointerEvents = pointer;
}
if (DOM.stopBtn) {
DOM.stopBtn.style.opacity = opacity;
DOM.stopBtn.style.pointerEvents = pointer;
}
if (DOM.autoCloseBtn) {
DOM.autoCloseBtn.style.opacity = "1";
DOM.autoCloseBtn.style.pointerEvents = "auto";
}
if (!onPost) {
if (DOM.resInfo) DOM.resInfo.textContent = "[not a post page]";
if (DOM.queueInfo) DOM.queueInfo.style.display = "none";
}
saveUIState();
}
function updateQueueUI() {
if (!DOM.queueInfo) return;
const queue = getQueue();
const processingText = worker.currentPostId
? ` | Processing: ${worker.currentPostId}`
: '';
if (queue.length > 0) {
DOM.queueInfo.textContent = `${queue.length} in queue${processingText}`;
DOM.queueInfo.style.display = "block";
} else {
DOM.queueInfo.style.display = "none";
}
}
function updateWorkerIndicator() {
if (!DOM.workerBadge) return;
if (worker.isWorker) {
DOM.workerBadge.textContent = "●";
DOM.workerBadge.style.color = "#00e676";
DOM.workerBadge.style.textShadow = "0 0 6px rgba(0,230,118,0.6)";
} else {
DOM.workerBadge.textContent = "○";
DOM.workerBadge.style.color = "#555";
DOM.workerBadge.style.textShadow = "none";
}
if (DOM.saveBtn) {
if (worker.isWorker) {
DOM.saveBtn.style.outline = "2px solid #00e676";
DOM.saveBtn.style.outlineOffset = "2px";
} else {
DOM.saveBtn.style.outline = "none";
}
}
}
function updateAutoCloseBtn() {
if (!DOM.autoCloseBtn) return;
if (ui.autoCloseEnabled) {
DOM.autoCloseBtn.style.background = "rgba(76,175,80,0.85)";
DOM.autoCloseBtn.style.color = "#fff";
DOM.autoCloseBtn.style.boxShadow = "0 0 10px rgba(76,175,80,0.4)";
} else {
DOM.autoCloseBtn.style.background = "rgba(96,125,139,0.6)";
DOM.autoCloseBtn.style.color = "#90a4ae";
DOM.autoCloseBtn.style.boxShadow = "none";
}
}
function setCollapsed(collapsed) {
ui.collapsed = collapsed;
if (DOM.body) {
DOM.body.style.maxHeight = collapsed ? "0" : "500px";
DOM.body.style.opacity = collapsed ? "0" : "1";
DOM.body.style.pointerEvents = collapsed ? "none" : "auto";
}
if (DOM.collapseBtn) {
DOM.collapseBtn.style.transform = collapsed ? "rotate(0deg)" : "rotate(180deg)";
}
}
function toggleCollapse() {
setCollapsed(!ui.collapsed);
savePanelState();
}
function showProgressBar(show) {
if (!DOM.progressOuter || !DOM.progressInner) return;
if (show) {
if (progress.interval) {
clearInterval(progress.interval);
progress.interval = null;
}
DOM.progressOuter.style.display = "block";
DOM.progressInner.style.width = "0%";
if (worker.isWorker) {
let width = 0;
progress.interval = setInterval(() => {
width += CONFIG.PROGRESS_INCREMENT;
if (width >= CONFIG.PROGRESS_MAX_SHOW) {
clearInterval(progress.interval);
progress.interval = null;
return;
}
DOM.progressInner.style.width = width + "%";
saveProgressState(true, width);
}, CONFIG.PROGRESS_INCREMENT_INTERVAL);
} else {
DOM.progressInner.style.width = "0%";
saveProgressState(true, 0);
}
} else {
if (progress.interval) {
clearInterval(progress.interval);
progress.interval = null;
}
DOM.progressInner.style.width = "100%";
saveProgressState(false, 100);
setTimeout(() => {
DOM.progressOuter.style.display = "none";
DOM.progressInner.style.width = "0%";
}, CONFIG.PROGRESS_HIDE_DELAY);
}
}
function bindButtons() {
if (!DOM.saveBtn) return;
DOM.saveBtn.onclick = async () => {
const url = getOriginalImageURL();
const tags = getTags();
const postId = getPostID();
if (!url) { toast("✗ No image URL"); return; }
if (!postId) { toast("✗ No post ID"); return; }
const added = await enqueueItem(url, tags, postId);
if (added) {
tryBecomeWorker();
if (ui.autoCloseEnabled) {
waitForAutoClose(postId);
}
}
};
DOM.autoCloseBtn.onclick = () => {
ui.autoCloseEnabled = !ui.autoCloseEnabled;
GM_setValue(KEYS.AUTO_CLOSE, ui.autoCloseEnabled);
updateAutoCloseBtn();
toast(ui.autoCloseEnabled ? "Auto-close ON" : "Auto-close OFF");
if (DOM.saveBtn) {
if (ui.autoCloseEnabled) {
DOM.saveBtn.textContent = "Save+Close";
DOM.saveBtn.title = "Alt+Z — add to queue & close tab";
} else {
DOM.saveBtn.textContent = "Save";
DOM.saveBtn.title = "Alt+Z — add to queue";
}
}
saveUIState();
};
DOM.parentBtn.onclick = async () => {
const id = getPostID();
if (!id) { toast("✗ No post ID"); return; }
GM_setValue(KEYS.PARENT_ID, id);
updateColors();
toast("Parent set: " + id);
const url = getOriginalImageURL();
const tags = getTags();
if (!url) { toast("✗ No image URL"); return; }
const added = await enqueueItem(url, tags, id);
if (added) {
tryBecomeWorker();
if (ui.autoCloseEnabled) {
waitForAutoClose(id);
}
}
};
DOM.stopBtn.onclick = () => {
GM_deleteValue(KEYS.PARENT_ID);
resignWorker();
clearQueue();
updateColors();
toast("Parent cleared, worker stopped & queue cleared");
};
}
function setupHotkeys() {
document.addEventListener("keydown", (e) => {
if (!e.altKey) return;
if (e.code === "KeyZ") {
DOM.saveBtn?.click();
e.preventDefault();
}
if (e.code === "KeyX") {
DOM.parentBtn?.click();
e.preventDefault();
}
if (e.code === "KeyC") {
DOM.stopBtn?.click();
e.preventDefault();
}
if (e.code === "KeyQ") {
clearQueue();
e.preventDefault();
}
if (e.code === "KeyS") {
if (ui.autoCloseEnabled) {
DOM.saveBtn?.click();
} else {
DOM.autoCloseBtn?.click();
}
e.preventDefault();
}
});
}
function showSettingsModal(callback) {
if (document.getElementById("eagle-settings-modal")) return;
const overlay = document.createElement("div");
overlay.id = "eagle-settings-modal";
overlay.style.cssText = "position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:999999;display:flex;align-items:center;justify-content:center;";
const modal = document.createElement("div");
modal.style.cssText = "background:#1e1e1e;color:#e0e0e0;padding:24px;border-radius:12px;width:400px;max-width:90%;font-family:'Segoe UI',Tahoma,sans-serif;box-shadow:0 8px 32px rgba(0,0,0,0.5);";
modal.innerHTML = `
<h3 style="margin:0 0 20px 0;font-size:18px;font-weight:600;">⚙️ Eagle Saver Settings</h3>
<div style="margin-bottom:16px;">
<label style="display:block;margin-bottom:6px;font-size:13px;color:#aaa;">API Token</label>
<input id="eagle-settings-token" type="text" value="${GM_getValue(KEYS.API_TOKEN, CONFIG.DEFAULT_TOKEN)}"
style="width:100%;padding:8px 12px;background:#2d2d2d;border:1px solid #444;border-radius:6px;color:#e0e0e0;font-size:13px;box-sizing:border-box;"
placeholder="Your Eagle API token">
</div>
<div style="margin-bottom:20px;">
<label style="display:block;margin-bottom:6px;font-size:13px;color:#aaa;">Port</label>
<input id="eagle-settings-port" type="text" value="${GM_getValue(KEYS.API_PORT, CONFIG.DEFAULT_PORT)}"
style="width:100%;padding:8px 12px;background:#2d2d2d;border:1px solid #444;border-radius:6px;color:#e0e0e0;font-size:13px;box-sizing:border-box;"
placeholder="41595">
</div>
<div style="display:flex;gap:8px;justify-content:flex-end;align-items:center;">
<button id="eagle-settings-reset-key"
style="padding:8px 16px;background:#f44336;color:white;border:none;border-radius:6px;cursor:pointer;font-size:13px;">Reset Token</button>
<div style="flex:1;"></div>
<button id="eagle-settings-cancel"
style="padding:8px 16px;background:#444;color:#e0e0e0;border:none;border-radius:6px;cursor:pointer;font-size:13px;">Cancel</button>
<button id="eagle-settings-save"
style="padding:8px 16px;background:#4caf50;color:white;border:none;border-radius:6px;cursor:pointer;font-size:13px;font-weight:bold;">Save</button>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
setTimeout(() => document.getElementById("eagle-settings-token").focus(), CONFIG.DEBOUNCE_RESET_DELAY);
const handleEnter = (e) => {
if (e.key === "Enter") document.getElementById("eagle-settings-save").click();
};
document.getElementById("eagle-settings-token").addEventListener("keydown", handleEnter);
document.getElementById("eagle-settings-port").addEventListener("keydown", handleEnter);
document.getElementById("eagle-settings-reset-key").onclick = () => {
GM_deleteValue(KEYS.API_TOKEN);
GM_deleteValue(KEYS.SETTINGS_DISMISSED);
document.getElementById("eagle-settings-token").value = "";
document.getElementById("eagle-settings-token").placeholder = "Token removed";
document.getElementById("eagle-settings-token").focus();
toast("⚠️ Token cleared");
};
document.getElementById("eagle-settings-cancel").onclick = () => {
GM_setValue(KEYS.SETTINGS_DISMISSED, true);
overlay.remove();
if (callback) callback(false);
};
document.getElementById("eagle-settings-save").onclick = () => {
const token = document.getElementById("eagle-settings-token").value.trim();
const port = document.getElementById("eagle-settings-port").value.trim();
if (!token || !port) {
alert("Token and port are required!");
return;
}
GM_setValue(KEYS.API_TOKEN, token);
GM_setValue(KEYS.API_PORT, port);
GM_setValue(KEYS.SETTINGS_DISMISSED, true);
overlay.remove();
if (callback) callback(true);
};
overlay.onclick = (e) => {
if (e.target === overlay) {
document.getElementById("eagle-settings-cancel").click();
}
};
}
function checkSettings() {
const token = GM_getValue(KEYS.API_TOKEN, null);
const port = GM_getValue(KEYS.API_PORT, null);
const dismissed = GM_getValue(KEYS.SETTINGS_DISMISSED, false);
if ((!token || !port) && !dismissed) {
showSettingsModal((saved) => {
if (saved) setTimeout(() => toast("Settings saved!"), CONFIG.SYNC_APPLY_DELAY);
});
}
}
function isRule34() { return location.hostname.includes("rule34"); }
function isDanbooru() { return location.hostname.includes("danbooru"); }
function isSankaku() { return location.hostname.includes("sankaku"); }
function isGelbooru() { return location.hostname.includes("gelbooru"); }
function isKonachan() { return location.hostname.includes("konachan"); }
function isPostPage() {
return getPostID() !== null;
}
function getPostID() {
if (isRule34() || isGelbooru()) {
return new URLSearchParams(location.search).get("id");
}
if (isDanbooru()) {
const m = location.pathname.match(/posts\/(\d+)/);
return m ? m[1] : null;
}
if (isSankaku()) {
const m = location.pathname.match(/posts\/(\w+)/);
return m ? m[1] : null;
}
if (isKonachan()) {
const m = location.pathname.match(/post\/show\/(\d+)/);
return m ? m[1] : null;
}
return null;
}
function getOriginalImageURL() {
if (isRule34() || isGelbooru()) {
let origLink = Array.from(document.querySelectorAll('li a, div a')).find(a =>
/original image/i.test(a.textContent) ||
(a.parentElement && /original image/i.test(a.parentElement.textContent))
);
if (origLink) return origLink.href;
let img = document.querySelector("#image");
if (img) {
if (img.parentElement && img.parentElement.tagName === 'A') {
return img.parentElement.href;
}
return img.src;
}
}
if (isDanbooru()) {
let dl = document.querySelector("#post-option-download a, #image-download-link");
if (dl) return dl.href;
let img = document.querySelector("#image");
if (img) return img.src;
}
if (isSankaku()) {
let highres = document.querySelector('#highres');
if (highres) {
let url = highres.href;
if (url.startsWith("./") || url.startsWith("/")) {
let img = document.querySelector("#image");
if (img) return img.src;
}
return url;
}
let img = document.querySelector("#image");
if (img) return img.src;
}
if (isKonachan()) {
let png = document.querySelector('a.png');
if (png) return png.href;
let highres = document.querySelector('#highres, #highres-show a');
if (highres) return highres.href;
let img = document.querySelector("#image");
if (img) return img.src;
}
return null;
}
function getImageResolution() {
if (isSankaku()) {
const stats = document.querySelector('#stats');
if (stats) {
const m = stats.textContent.match(/Original:\s*(\d+)\s*[x×]\s*(\d+)/i);
if (m) return `[${m[1]}x${m[2]}]`;
}
}
if (isDanbooru()) {
const sizeLi = Array.from(document.querySelectorAll('#post-information li'))
.find(li => li.textContent.includes('Size'));
if (sizeLi) {
const m = sizeLi.textContent.match(/(\d+)\s*[x×]\s*(\d+)/);
if (m) return `[${m[1]}x${m[2]}]`;
}
}
if (isRule34() || isGelbooru() || isKonachan()) {
const stats = document.querySelector('#stats, .sidebar, #tag-sidebar');
if (stats) {
const m = stats.textContent.match(/Size:\s*(\d+)\s*[x×]\s*(\d+)/i) ||
stats.textContent.match(/(\d+)\s*[x×]\s*(\d+)/);
if (m) return `[${m[1]}x${m[2]}]`;
}
}
const img = document.querySelector("#image");
if (img && img.naturalWidth && img.naturalWidth > 500) {
return `[${img.naturalWidth}x${img.naturalHeight}]`;
}
return "[Res: ?]";
}
function cleanTag(tag) {
return tag.trim().replace(/_/g, " ").toLowerCase();
}
function getTags() {
const tags = [];
const artists = [];
const seenArtists = new Set();
const seenTags = new Set();
const addArtist = (t) => {
if (t && t !== "?" && !seenArtists.has(t)) {
seenArtists.add(t);
artists.push("artist:" + t);
}
};
const addTag = (t) => {
if (t && t !== "?" && !seenTags.has(t)) {
seenTags.add(t);
tags.push(t);
}
};
if (isRule34()) {
document.querySelectorAll("#tag-sidebar li a").forEach(a => {
const t = cleanTag(a.textContent);
const li = a.parentElement;
const isArtist = li && li.classList.contains("tag-type-artist");
if (isArtist) addArtist(t);
else addTag(t);
});
}
if (isDanbooru()) {
document.querySelectorAll(".artist-tag-list li a").forEach(a => addArtist(cleanTag(a.textContent)));
document.querySelectorAll(".general-tag-list li a, .character-tag-list li a, .copyright-tag-list li a, .meta-tag-list li a").forEach(a => addTag(cleanTag(a.textContent)));
}
if (isSankaku()) {
document.querySelectorAll('#tag-sidebar li a').forEach(a => {
const t = cleanTag(a.textContent);
const li = a.parentElement;
const isArtist = li && (
li.dataset.tagType === "artist" ||
li.classList.contains("tag-type-artist")
);
if (isArtist) addArtist(t);
else addTag(t);
});
}
if (isGelbooru() || isKonachan()) {
document.querySelectorAll("li a").forEach(a => {
const t = cleanTag(a.textContent);
const li = a.parentElement;
const type = li ? li.className.match(/tag-type-(\w+)/) : null;
if (type && type[1] === "artist") addArtist(t);
else if (type && ["general", "character", "copyright"].includes(type[1])) addTag(t);
else addTag(t);
});
}
const parentID = GM_getValue(KEYS.PARENT_ID, null);
if (parentID) {
tags.push("parent:" + parentID);
}
return [...new Set([...artists, ...tags])];
}
function init() {
log('info', `Initializing v2.0.3, tab: ${TAB_ID}`);
createPanel();
bindButtons();
setupHotkeys();
setupValueChangeListeners();
startWorkerRecoveryCheck();
checkSettings();
const queue = getQueue();
if (queue.length > 0) {
log('info', `Queue has ${queue.length} items, trying to become worker`);
tryBecomeWorker();
}
let lastUrl = location.href;
const urlObserver = new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
updatePostPageUI();
if (DOM.resInfo) {
DOM.resInfo.textContent = getImageResolution();
}
}
});
urlObserver.observe(document.body, { childList: true, subtree: true });
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
log('info', 'Tab became visible — refreshing UI state');
try {
const uiStateRaw = GM_getValue(KEYS.UI_STATE, null);
if (uiStateRaw) {
const uiState = JSON.parse(uiStateRaw);
applyUIState(uiState);
}
} catch (e) {
log('warn', 'visibilitychange: UI_STATE parse error', e);
}
updatePostPageUI();
updateQueueUI();
updateWorkerIndicator();
if (DOM.resInfo) {
const onPost = isPostPage();
const resolution = getImageResolution();
DOM.resInfo.textContent = onPost ? resolution : "[not a post page]";
}
}
});
window.addEventListener('focus', () => {
log('info', 'Window focused — refreshing queue and worker info');
updateQueueUI();
updateWorkerIndicator();
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
if (typeof GM_registerMenuCommand === "function") {
GM_registerMenuCommand("Eagle Saver Settings", () => {
showSettingsModal((saved) => {
if (saved) toast("Settings saved!");
});
});
}
window.addEventListener('beforeunload', () => {
log('info', 'Tab closing, cleaning up');
stopHeartbeat();
stopWorkerLoop();
if (autoClose.timer) clearTimeout(autoClose.timer);
if (progress.interval) clearInterval(progress.interval);
if (worker.isWorker) {
const active = getActiveWorker();
if (active && active.tabId === TAB_ID) {
GM_setValue(KEYS.WORKER, null);
log('info', 'Worker status cleared');
}
}
});
})();