Sleazy Fork is available in English.
Monitor new comments on e-hentai galleries.
// ==UserScript==
// @name E-Hentai Comment Watcher
// @namespace http://tampermonkey.net/
// @version 2.0
// @description Monitor new comments on e-hentai galleries.
// @author moodyclaus
// @match *://e-hentai.org/*
// @match *://exhentai.org/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_notification
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant GM_addValueChangeListener
// @grant GM.xmlHttpRequest
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.notification
// @grant GM.addStyle
// @grant GM.addValueChangeListener
// @connect e-hentai.org
// @connect exhentai.org
// @exclude *://e-hentai.org/archiver.php*
// @exclude *://exhentai.org/archiver.php*
// @exclude *://e-hentai.org/gallery*
// @exclude *://exhentai.org/gallery*
// @run-at document-end
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// --- Configuration & Constants ---
const LOCK_TIMEOUT = 5 * 60 * 1000;
const STORAGE_KEY = 'eh_comment_watcher_data';
const LOCK_KEY = 'eh_comment_watcher_lock';
const LAST_CHECK_KEY = 'eh_comment_watcher_last_check';
const SETTINGS_KEY = 'eh_comment_watcher_settings';
const REFRESH_STATE_KEY = 'eh_comment_watcher_refresh_state';
let _needsRender = true;
const DEFAULT_SETTINGS = {
refreshInterval: 30,
username: '',
alertOnEdits: false,
openInNewTab: true,
enableNotifications: true,
ignoreUploaderComment: false
};
const THEME = {
primary: '#f1f1f1',
accent: '#5ca945',
bg: 'rgba(26, 26, 26, 0.95)',
text: '#ddd',
border: '#444',
shadow: '0 8px 32px 0 rgba(0, 0, 0, 0.8)'
};
// --- Compatibility Wrapper ---
const _GM_getValue = typeof GM_getValue !== 'undefined' ? GM_getValue : (typeof GM !== 'undefined' ? GM.getValue : null);
const _GM_setValue = typeof GM_setValue !== 'undefined' ? GM_setValue : (typeof GM !== 'undefined' ? GM.setValue : null);
const _GM_xmlhttpRequest = typeof GM_xmlhttpRequest !== 'undefined' ? GM_xmlhttpRequest : (typeof GM !== 'undefined' ? GM.xmlHttpRequest : null);
const _GM_notification = typeof GM_notification !== 'undefined' ? GM_notification : null;
const _GM_addValueChangeListener = typeof GM_addValueChangeListener !== 'undefined' ? GM_addValueChangeListener : (typeof GM !== 'undefined' && GM.addValueChangeListener ? GM.addValueChangeListener : null);
const _GM_addStyle = typeof GM_addStyle !== 'undefined' ? GM_addStyle : (css => {
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
});
// --- State Management ---
async function getState() {
try {
const val = _GM_getValue ? await _GM_getValue(STORAGE_KEY) : null;
return val || { watched: {} };
} catch (e) {
console.error('[EH-CW] Error getting state:', e);
return { watched: {} };
}
}
async function saveState(state) {
try {
if (_GM_setValue) await _GM_setValue(STORAGE_KEY, state);
broadcastSync();
} catch (e) {
console.error('[EH-CW] Error saving state:', e);
}
}
async function getSettings() {
try {
const val = _GM_getValue ? await _GM_getValue(SETTINGS_KEY) : null;
return { ...DEFAULT_SETTINGS, ...(val || {}) };
} catch (e) {
console.error('[EH-CW] Error getting settings:', e);
return { ...DEFAULT_SETTINGS };
}
}
async function saveSettings(settings) {
try {
if (_GM_setValue) await _GM_setValue(SETTINGS_KEY, settings);
if (!_GM_addValueChangeListener && _syncChannel) {
_syncChannel.postMessage({ type: 'settings', settings: settings });
}
} catch (e) {
console.error('[EH-CW] Error saving settings:', e);
}
}
async function updateSetting(key, value) {
const settings = await getSettings();
settings[key] = value;
await saveSettings(settings);
}
async function getCheckIntervalMs() {
const settings = await getSettings();
const mins = parseInt(settings.refreshInterval, 10);
return (isNaN(mins) || mins < 5 ? 5 : mins) * 60 * 1000;
}
// --- Lock handling on page unload ---
let _ownsLock = false;
function releaseLockOnUnload() {
if (_ownsLock) {
_ownsLock = false;
// Best effort release when tab closes mid-scan
if (_GM_setValue) _GM_setValue(LOCK_KEY, 0);
// Notify other tabs that the refresh was aborted
setRefreshing(false);
}
}
window.addEventListener('pagehide', releaseLockOnUnload);
window.addEventListener('beforeunload', releaseLockOnUnload);
// --- Cross-tab sync via GM listener or BroadcastChannel ---
const _syncChannel = (typeof BroadcastChannel !== 'undefined' && !_GM_addValueChangeListener) ? new BroadcastChannel('eh_comment_watcher_sync') : null;
if (_GM_addValueChangeListener) {
_GM_addValueChangeListener(STORAGE_KEY, async (name, old_value, new_value, remote) => {
if (remote) {
const parsedState = typeof new_value === 'string' ? JSON.parse(new_value) : new_value;
await updateUI(parsedState);
}
});
_GM_addValueChangeListener(REFRESH_STATE_KEY, (name, old_value, new_value, remote) => {
if (remote) {
_isRefreshing = !!new_value;
updateRefreshButton(_isRefreshing);
}
});
_GM_addValueChangeListener(SETTINGS_KEY, (name, old_value, new_value, remote) => {
if (remote && new_value && typeof new_value.openInNewTab !== 'undefined') {
applyOpenInNewTab(new_value.openInNewTab);
}
});
} else if (_syncChannel) {
_syncChannel.onmessage = async (event) => {
const msg = event.data;
if (msg && typeof msg === 'object') {
if (msg.type === 'refresh') {
_isRefreshing = msg.inProgress;
updateRefreshButton(_isRefreshing);
} else if (msg.type === 'settings') {
if (msg.settings && typeof msg.settings.openInNewTab !== 'undefined') {
applyOpenInNewTab(msg.settings.openInNewTab);
}
}
} else {
await updateUI();
}
};
}
let _broadcastTimer = null;
function broadcastSync() {
if (!_syncChannel) return;
// Debounce: coalesce rapid successive saves into a single broadcast (fallback only)
if (_broadcastTimer) clearTimeout(_broadcastTimer);
_broadcastTimer = setTimeout(() => {
_syncChannel.postMessage('update');
_broadcastTimer = null;
}, 300);
}
// --- Refresh state: shared across all open tabs ---
let _isRefreshing = false;
function updateRefreshButton(inProgress) {
const btn = document.querySelector('.refresh-all');
if (!btn) return;
btn.textContent = inProgress ? '...' : '🔄';
btn.title = inProgress ? 'Refreshing...' : 'Refresh all galleries';
btn.disabled = inProgress;
}
function setRefreshing(inProgress) {
_isRefreshing = inProgress;
updateRefreshButton(inProgress);
if (_GM_addValueChangeListener && _GM_setValue) {
_GM_setValue(REFRESH_STATE_KEY, inProgress);
} else if (_syncChannel) {
_syncChannel.postMessage({ type: 'refresh', inProgress });
}
}
async function openSettings() {
const modal = document.getElementById('eh-settings-modal');
if (!modal) return;
const settings = await getSettings();
const intervalInput = modal.querySelector('#eh-setting-interval');
const usernameInput = modal.querySelector('#eh-setting-username');
const editsCheckbox = modal.querySelector('#eh-setting-edits');
const ignoreUploaderCheckbox = modal.querySelector('#eh-setting-ignore-uploader');
const newTabCheckbox = modal.querySelector('#eh-setting-newtab');
const notifCheckbox = modal.querySelector('#eh-setting-notif');
if (intervalInput) intervalInput.value = settings.refreshInterval || 30;
if (usernameInput) usernameInput.value = settings.username || '';
if (editsCheckbox) editsCheckbox.checked = !!settings.alertOnEdits;
if (ignoreUploaderCheckbox) ignoreUploaderCheckbox.checked = !!settings.ignoreUploaderComment;
if (newTabCheckbox) newTabCheckbox.checked = settings.openInNewTab !== false;
if (notifCheckbox) notifCheckbox.checked = settings.enableNotifications !== false;
modal.classList.add('open');
const panel = document.getElementById('eh-watcher-panel');
if (panel) panel.classList.add('settings-active');
}
// --- Utilities ---
const escapeHtml = (unsafe) => {
return (unsafe || '').toString().replace(/[&<"'>]/g, function (match) {
const escapeMap = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
return escapeMap[match];
});
};
const getGalleryId = (url) => {
const match = url.match(/\/g\/(\d+)\/([a-z0-9]+)/);
return match ? `${match[1]}_${match[2]}` : null;
};
const normalizeGalleryUrl = (url) => {
let base = url.split(/[?#]/)[0];
if (!base.endsWith('/')) base += '/';
return base;
};
const isGalleryPage = () => {
return window.location.pathname.startsWith('/g/') && getGalleryId(window.location.href);
};
const findNewerVersion = (htmlDoc) => {
const gnd = htmlDoc.getElementById('gnd');
if (!gnd) return null;
const links = gnd.querySelectorAll('a[href*="/g/"]');
// Return the last link, which is the most recent version
return links.length > 0 ? links[links.length - 1].href : null;
};
const parseEHDate = (str) => {
const months = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
const match = str.match(/(\d{1,2}) (\w+) (\d{4}), (\d{2}):(\d{2})/);
if (!match) return null;
const [_, day, monthStr, year, hour, min] = match;
const monthIndex = months.indexOf(monthStr.toLowerCase().substring(0, 3));
if (monthIndex === -1) return null;
return Date.UTC(parseInt(year), monthIndex, parseInt(day), parseInt(hour), parseInt(min));
};
// --- Core Logic: Comment Extraction ---
function extractLatestComments(htmlDoc, lastTimestamp = 0, lastEditTimestamp = 0, filterUsername = '', alertOnEdits = false, ignoreUploaderComment = false) {
let commentDivs = Array.from(htmlDoc.querySelectorAll('#cdiv .c1'));
if (ignoreUploaderComment) {
commentDivs = commentDivs.filter(div =>
!div.querySelector('#comment_0')
);
}
if (!commentDivs.length) return { timestamp: 0, count: 0, newCount: 0, lastCommenter: '', latestEditTs: 0, newEditCount: 0, oldestUnreadId: null, lastCommentId: null };
const normalizedFilter = filterUsername.trim().toLowerCase();
let latestTs = 0;
let lastCommenter = '';
let lastCommentId = null;
let newCount = 0;
let latestEditTs = 0;
let newEditCount = 0;
let minUnreadTs = Infinity;
let oldestUnreadId = null;
commentDivs.forEach(div => {
const header = div.querySelector('.c3');
const commenterLink = header ? header.querySelector('a') : null;
const commenterName = commenterLink ? commenterLink.textContent.trim() : '';
// Own-comment check: only active when a non-empty filter is configured
const isOwnComment = normalizedFilter !== '' && commenterName.toLowerCase() === normalizedFilter;
const c6 = div.querySelector('.c6');
const commentId = c6 ? c6.id : null;
if (header) {
const ts = parseEHDate(header.textContent);
if (ts) {
// Always update display metadata (unfiltered)
if (ts > latestTs) {
latestTs = ts;
lastCommenter = commenterName || 'Unknown';
lastCommentId = commentId;
}
// Only count toward new if not own comment
if (ts > lastTimestamp && !isOwnComment) {
newCount++;
if (ts < minUnreadTs && commentId) {
minUnreadTs = ts;
oldestUnreadId = commentId;
}
}
}
}
// Parse edit timestamp from div.c8 ("Last edited on <strong>DD Month YYYY, HH:MM</strong>.")
const editDiv = div.querySelector('.c8');
if (editDiv) {
const strongEl = editDiv.querySelector('strong');
if (strongEl) {
const editTs = parseEHDate(editDiv.textContent);
if (editTs) {
// Always update display metadata (unfiltered)
if (editTs > latestEditTs) latestEditTs = editTs;
// Only count toward new edits if not own edit
if (editTs > lastEditTimestamp && !isOwnComment) {
newEditCount++;
if (alertOnEdits && editTs < minUnreadTs && commentId) {
minUnreadTs = editTs;
oldestUnreadId = commentId;
}
}
}
}
}
});
return {
timestamp: latestTs,
count: commentDivs.length,
newCount: newCount,
lastCommenter: lastCommenter,
lastCommentId: lastCommentId,
latestEditTs: latestEditTs,
newEditCount: newEditCount,
oldestUnreadId: oldestUnreadId
};
}
// --- Background Polling with Coordination ---
async function checkGalleries(force = false) {
const now = Date.now();
const lastGlobalCheck = (await _GM_getValue(LAST_CHECK_KEY)) || 0;
const currentLock = (await _GM_getValue(LOCK_KEY)) || 0;
if (!force) {
const intervalMs = await getCheckIntervalMs();
if (now - lastGlobalCheck < intervalMs) return;
if (currentLock && (now - currentLock < LOCK_TIMEOUT)) return;
}
await _GM_setValue(LOCK_KEY, now);
_ownsLock = true;
console.log('[EH-CW] ' + (force ? 'Manual' : 'Lock') + ' acquired. Starting background poll...');
setRefreshing(true);
try {
const state = await getState();
// Sort galleries so the ones checked longest ago are processed first
const galleries = Object.values(state.watched || {}).sort((a, b) => (a.lastChecked || 0) - (b.lastChecked || 0));
// Hoist settings above the loop — no need to re-read for every gallery
const settings = await getSettings();
const filterUsername = (settings.username || '').trim();
if (galleries.length === 0) {
await _GM_setValue(LAST_CHECK_KEY, now);
await _GM_setValue(LOCK_KEY, 0);
} else {
let chunkUpdates = [];
let firstNotification = null; // title/commenter for the first new-activity transition
let processedInChunk = 0;
const CHUNK_SIZE = 3;
for (let i = 0; i < galleries.length; i++) {
const gallery = galleries[i];
const stagger = 500 + Math.random() * 1000;
await new Promise(resolve => setTimeout(resolve, stagger));
try {
await new Promise((resolve, reject) => {
_GM_xmlhttpRequest({
method: 'GET',
url: gallery.url,
onload: async (response) => {
const parser = new DOMParser();
const doc = parser.parseFromString(response.responseText, 'text/html');
const newerUrl = findNewerVersion(doc);
// Extract info statically
let isUnavailable = false;
let info = null;
let newId = null;
let newTitle = null;
let docTitle = null;
if (newerUrl) {
newId = getGalleryId(newerUrl);
const newerLink = doc.querySelector('#gnd a');
newTitle = newerLink ? newerLink.textContent.trim() : null;
} else {
isUnavailable = !!doc.getElementById('continue');
if (!isUnavailable) {
info = extractLatestComments(doc, gallery.lastTimestamp, gallery.lastEditTimestamp || 0, filterUsername, settings.alertOnEdits, settings.ignoreUploaderComment);
const titleEl = doc.getElementById('gn') || doc.getElementById('gj');
if (titleEl) docTitle = titleEl.textContent.trim();
}
}
chunkUpdates.push((currentState) => {
const g = currentState.watched[gallery.id];
if (!g) return false;
let updated = false;
if (newerUrl) {
if (newId && newId !== g.id) {
console.log(`[EH-CW] Switching gallery ${g.id} to newer version ${newId}`);
delete currentState.watched[g.id];
currentState.watched[newId] = {
...g, id: newId, url: normalizeGalleryUrl(newerUrl), title: newTitle || g.title,
lastTimestamp: 0, hasNew: true, newComments: 1,
lastCommenter: 'System (Newer Version available)',
lastChecked: Date.now()
};
if (!firstNotification) firstNotification = { title: g.title, commenter: 'New version detected and swapped.', type: 'System', url: newerUrl, id: newId };
updated = true;
} else {
g.lastChecked = Date.now();
}
} else {
g.lastChecked = Date.now();
if (docTitle && docTitle !== g.title) {
g.title = docTitle;
updated = true;
}
const wasUnavailable = !!g.unavailable;
if (isUnavailable !== wasUnavailable) {
g.unavailable = isUnavailable;
updated = true;
}
if (!isUnavailable && info) {
if (info.timestamp > g.lastTimestamp) {
g.lastTimestamp = info.timestamp;
g.lastCommenter = info.lastCommenter;
g.lastCommentId = info.lastCommentId;
g.count = info.count;
updated = true;
if (info.newCount > 0) {
const wasNew = g.hasNew;
g.newComments = (g.newComments || 0) + info.newCount;
g.hasNew = true;
if (!g.oldestUnreadId && info.oldestUnreadId) g.oldestUnreadId = info.oldestUnreadId;
if (!wasNew && !firstNotification) firstNotification = { title: g.title, commenter: info.lastCommenter, type: 'Comment', url: g.url, id: g.id, oldestUnreadId: info.oldestUnreadId };
}
} else if (info.count !== g.count) {
g.count = info.count;
g.lastTimestamp = info.timestamp;
g.lastCommenter = info.lastCommenter;
g.lastCommentId = info.lastCommentId;
updated = true;
}
if (settings.alertOnEdits && info.latestEditTs > (g.lastEditTimestamp || 0)) {
g.lastEditTimestamp = info.latestEditTs;
updated = true;
if (info.newEditCount > 0) {
const wasNew = g.hasNew;
g.newEdits = (g.newEdits || 0) + info.newEditCount;
g.hasNew = true;
if (!g.oldestUnreadId && info.oldestUnreadId) g.oldestUnreadId = info.oldestUnreadId;
if (!wasNew && !firstNotification) firstNotification = { title: g.title, commenter: info.lastCommenter, type: 'Edit', url: g.url, id: g.id, oldestUnreadId: info.oldestUnreadId };
}
}
if (g.newComments > g.count) {
g.newComments = g.count;
updated = true;
}
if (g.newComments === 0 && !g.newEdits) {
if (g.hasNew) {
g.hasNew = false;
updated = true;
}
if (g.oldestUnreadId) {
delete g.oldestUnreadId;
updated = true;
}
}
}
}
return updated;
});
resolve();
},
onerror: reject
});
});
} catch (err) {
console.error(`[EH-CW] Failed to check gallery ${gallery.id}:`, err);
}
processedInChunk++;
if (processedInChunk >= CHUNK_SIZE && chunkUpdates.length > 0) {
const currentState = await getState();
let chunkChanged = false;
for (const fn of chunkUpdates) {
if (fn(currentState)) chunkChanged = true;
}
if (chunkChanged) {
if (firstNotification) {
await triggerNotification(firstNotification, settings);
firstNotification = null;
}
await saveState(currentState);
await updateUI(currentState);
}
chunkUpdates = [];
processedInChunk = 0;
}
}
// Final save + UI update for any remaining galleries in the last chunk
if (chunkUpdates.length > 0) {
const currentState = await getState();
let chunkChanged = false;
for (const fn of chunkUpdates) {
if (fn(currentState)) chunkChanged = true;
}
if (chunkChanged) {
if (firstNotification) {
await triggerNotification(firstNotification, settings);
firstNotification = null;
}
await saveState(currentState);
await updateUI(currentState);
}
}
} // end of else (galleries.length > 0)
await _GM_setValue(LAST_CHECK_KEY, Date.now());
await _GM_setValue(LOCK_KEY, 0);
console.log('[EH-CW] Poll complete. Lock released.');
} finally {
_ownsLock = false;
setRefreshing(false);
}
}
async function triggerNotification(notificationData, settingsObj = null) {
const settings = settingsObj || await getSettings();
if (!settings.enableNotifications) return;
if (_GM_notification) {
const isEdit = notificationData.type === 'Edit';
let titleText = 'New Comment on E-Hentai';
if (isEdit) titleText = 'New Edit on E-Hentai';
else if (notificationData.type === 'System') titleText = 'New Version Detected';
const targetUrl = notificationData.oldestUnreadId ? `${notificationData.url}#${notificationData.oldestUnreadId}` : notificationData.url;
_GM_notification({
title: titleText,
text: `Gallery: ${notificationData.title}\nLatest by: ${notificationData.commenter}`,
url: targetUrl,
onclick: async (event) => {
event.preventDefault();
const settings = await getSettings();
const linkTarget = settings.openInNewTab !== false ? '_blank' : '_self';
if (notificationData.id) {
await markAsRead(notificationData.id);
}
if (targetUrl) {
window.open(targetUrl, linkTarget);
}
}
});
}
}
// --- UI: Watch Button ---
async function injectWatchButton() {
try {
if (!isGalleryPage()) return;
const container = document.getElementById('gd5');
if (!container) {
setTimeout(injectWatchButton, 1000);
return;
}
const id = getGalleryId(window.location.href);
let btn = document.getElementById('eh-watch-btn');
if (!btn) {
btn = document.createElement('p');
btn.className = 'g3';
btn.id = 'eh-watch-btn';
container.appendChild(btn);
}
const state = await getState();
const newerUrl = findNewerVersion(document);
if (newerUrl) {
if (btn) btn.style.display = 'none';
return;
}
let isWatched = !!(state && state.watched && state.watched[id]);
let displayLabel = isWatched ? 'Unwatch comments' : 'Watch Comments';
const img = container.querySelector('img');
const imgSrc = img ? img.src : 'https://e-hentai.org/img/mr.gif';
const linkStyle = `font-weight: bold; cursor: pointer; text-decoration: none; color: inherit`;
btn.innerHTML = `<img src="${imgSrc}" style="vertical-align:middle; margin-right:5px"><a href="#" onclick="return false" style="${linkStyle}">${displayLabel}</a>`;
btn.onclick = async (e) => {
e.preventDefault();
try {
const currentState = await getState();
if (currentState.watched && currentState.watched[id]) {
delete currentState.watched[id];
} else {
const settings = await getSettings();
const info = extractLatestComments(document, 0, 0, '', settings.alertOnEdits, settings.ignoreUploaderComment);
const titleEl = document.getElementById('gn') || document.getElementById('gj');
const title = titleEl ? titleEl.textContent.trim() : 'Unknown Gallery';
if (!currentState.watched) currentState.watched = {};
currentState.watched[id] = {
id: id, url: normalizeGalleryUrl(window.location.href), title: title,
lastTimestamp: info.timestamp, hasNew: false, newComments: 0,
count: info.count, lastCommenter: info.lastCommenter,
lastCommentId: info.lastCommentId,
lastChecked: Date.now()
};
}
await saveState(currentState);
await injectWatchButton();
await updateUI(currentState);
} catch (clickErr) {
console.error('[EH-CW] Error during button click:', clickErr);
}
};
} catch (err) {
console.error('[EH-CW] Error in injectWatchButton:', err);
}
}
// --- UI: Badge & Panel ---
async function updateUI(state) {
if (!state) state = await getState();
const watchedList = Object.values(state.watched || {});
const badge = document.getElementById('eh-watcher-badge') || createBadge();
const newCount = watchedList.filter(g => g.hasNew).length;
if (watchedList.length === 0) {
badge.style.display = 'none';
} else {
badge.style.display = 'flex';
const countEl = badge.querySelector('.count');
if (countEl) countEl.textContent = newCount;
badge.classList.toggle('has-new', newCount > 0);
}
const panel = document.getElementById('eh-watcher-panel');
if (panel && panel.classList.contains('open')) {
await renderPanel(state);
} else {
_needsRender = true;
}
}
function createBadge() {
const badge = document.createElement('div');
badge.id = 'eh-watcher-badge';
badge.innerHTML = `<span class="icon">💬</span><span class="count">0</span>`;
document.body.appendChild(badge);
_GM_addStyle(`
#eh-watcher-badge {
position: fixed; bottom: 20px; right: 20px; z-index: 10000;
width: 50px; height: 50px; border-radius: 50%;
background: ${THEME.bg}; border: 1px solid ${THEME.border};
display: flex; align-items: center; justify-content: center;
cursor: pointer; color: ${THEME.text}; font-weight: bold;
box-shadow: ${THEME.shadow}; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
}
#eh-watcher-badge.has-new { border-color: ${THEME.accent}; box-shadow: 0 0 15px ${THEME.accent}80; }
#eh-watcher-badge .count { position: absolute; top: -5px; right: -5px; background: #3d852a; color: white; font-size: 10px; padding: 2px 6px; border-radius: 10px; min-width: 12px; text-align: center; }
#eh-watcher-panel { position: fixed; bottom: 80px; right: 20px; z-index: 10001; width: 340px; max-height: 500px; background: ${THEME.bg}; border: 1px solid ${THEME.border}; border-radius: 12px; box-shadow: ${THEME.shadow}; display: none; flex-direction: column; padding: 0; color: ${THEME.text}; overflow: auto; text-align: left !important; }
#eh-watcher-main-view { width: 100%; max-height: 500px; display: flex; flex-direction: column; padding: 15px; box-sizing: border-box; overflow-y: auto; }
#eh-watcher-panel.open { display: flex; animation: ehSlideIn 0.3s ease; }
@keyframes ehSlideIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.eh-item { padding: 10px; border-bottom: 1px solid ${THEME.border}; transition: background 0.2s; }
.eh-item:last-child { border-bottom: none; }
.eh-item.has-new { background: rgba(92, 169, 69, 0.1); border-left: 3px solid ${THEME.accent}; }
.eh-title { font-weight: bold; font-size: 13px; color: ${THEME.accent}; text-decoration: none; display: block; margin-bottom: 5px; }
.eh-title-unavailable { color: #e74c3c !important; }
.eh-meta { font-size: 11px; color: #aaa; }
.eh-actions { margin-top: 8px; display: flex; gap: 8px; margin-left: 27px;}
.eh-btn { background: #333; border: 1px solid #555; color: #ddd; padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 11px; }
.eh-btn:hover { background: #444; }
.eh-btn.read { border-color: ${THEME.accent}; color: ${THEME.accent}; }
.eh-btn.danger { border-color: #a94442; color: #f2dede; background: #a9444220; }
.eh-toolbar { display: flex; gap: 8px; padding-bottom: 15px; border-bottom: 2px solid ${THEME.border}; margin-bottom: 10px; position: sticky; top: 0; background: ${THEME.bg}; z-index: 10; }
.eh-checkbox { cursor: pointer; width: 16px; height: 16px; margin-right: 10px; accent-color: ${THEME.accent}; }
.eh-item-main { display: flex; align-items: flex-start; }
.eh-item-content { flex-grow: 1; min-width: 0; }
.eh-settings-group {
display: flex;
flex-direction: column;
gap: 5px;
margin-bottom: 12px;
}
.eh-settings-label {
font-size: 12px;
font-weight: bold;
color: ${THEME.primary};
}
.eh-settings-input {
background: rgba(255, 255, 255, 0.05) !important;
border: 1px solid ${THEME.border} !important;
border-radius: 6px !important;
color: ${THEME.text} !important;
padding: 6px 10px !important;
font-size: 12px !important;
transition: border-color 0.2s, box-shadow 0.2s !important;
outline: none !important;
box-sizing: border-box !important;
}
.eh-settings-input::placeholder {
color: #8C8C8C !important;
-webkit-text-fill-color: #8C8C8C !important;
opacity: 1 !important;
}
.eh-settings-input:hover, .eh-settings-input:focus, .eh-settings-input:active {
background: rgba(255, 255, 255, 0.08) !important;
border-color: ${THEME.accent} !important;
color: ${THEME.text} !important;
box-shadow: 0 0 5px ${THEME.accent}80 !important;
outline: none !important;
}
.eh-settings-description {
font-size: 10px;
color: #888;
line-height: 1.4;
}
.eh-settings-checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 12px;
color: ${THEME.primary};
user-select: none;
}
.eh-settings-checkbox {
cursor: pointer;
width: 16px;
height: 16px;
accent-color: ${THEME.accent};
}
#eh-settings-modal {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: ${THEME.bg};
border-radius: 12px;
padding: 15px;
display: none;
flex-direction: column;
z-index: 10002;
box-sizing: border-box;
}
#eh-settings-modal.open {
display: flex;
animation: ehFadeIn 0.2s ease;
}
@keyframes ehFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
`);
const panel = document.createElement('div');
panel.id = 'eh-watcher-panel';
document.body.appendChild(panel);
const mainView = document.createElement('div');
mainView.id = 'eh-watcher-main-view';
mainView.innerHTML = `
<div class="eh-toolbar">
<input type="checkbox" id="eh-select-all" class="eh-checkbox" title="Select All">
<button class="eh-btn batch-read" title="Mark selected as read">Mark Read</button>
<button class="eh-btn batch-unwatch danger" title="Unwatch selected">Unwatch</button>
<button class="eh-btn refresh-all" id="eh-refresh-btn" title="Refresh all galleries">🔄</button>
<button class="eh-btn settings-toggle" title="Settings">⚙️</button>
<button class="eh-btn" style="margin-left:auto" onclick="document.getElementById('eh-watcher-panel').classList.remove('open')">✕</button>
</div>
<div id="eh-last-checked" style="font-size: 10px; color: #888; margin-bottom: 10px; padding-left: 5px;">
Last checked: Never
</div>
<div id="eh-items-container"></div>
`;
panel.appendChild(mainView);
mainView.onclick = async (e) => {
const target = e.target;
const id = target.getAttribute('data-id');
const state = await getState();
if (target.classList.contains('refresh-all')) {
await checkGalleries(true);
await renderPanel();
} else if (target.classList.contains('settings-toggle')) {
await openSettings();
} else if (target.classList.contains('batch-read')) {
const selectedIds = Array.from(mainView.querySelectorAll('.item-select:checked')).map(cb => cb.getAttribute('data-id'));
for (const sid of selectedIds) {
if (state.watched[sid]) {
state.watched[sid].hasNew = false;
state.watched[sid].newComments = 0;
state.watched[sid].newEdits = 0;
delete state.watched[sid].oldestUnreadId;
}
}
await saveState(state);
await updateUI(state);
} else if (target.classList.contains('batch-unwatch')) {
if (!confirm('Unwatch all selected galleries?')) return;
const selectedIds = Array.from(mainView.querySelectorAll('.item-select:checked')).map(cb => cb.getAttribute('data-id'));
for (const sid of selectedIds) delete state.watched[sid];
await saveState(state);
await updateUI(state);
await injectWatchButton();
} else if (target.classList.contains('eh-open-title')) {
const linkId = target.getAttribute('data-id');
if (linkId) await markAsRead(linkId);
} else if (id) {
if (target.classList.contains('read')) {
await markAsRead(id);
} else if (target.classList.contains('unwatch')) {
delete state.watched[id];
await saveState(state);
await updateUI(state);
await injectWatchButton();
}
}
};
const selectAll = mainView.querySelector('#eh-select-all');
if (selectAll) {
selectAll.onclick = () => {
const itemCheckboxes = mainView.querySelectorAll('.item-select');
itemCheckboxes.forEach(cb => cb.checked = selectAll.checked);
};
}
// Create settings modal overlay
const settingsModal = document.createElement('div');
settingsModal.id = 'eh-settings-modal';
settingsModal.innerHTML = `
<div class="eh-toolbar">
<span style="font-weight: bold; font-size: 13px; display: flex; align-items: center; gap: 6px;">⚙️ Settings</span>
<button class="eh-btn eh-settings-back" style="margin-left: auto;">Back</button>
</div>
<div id="eh-settings-container" style="display: flex; flex-direction: column; gap: 12px; padding: 10px 5px;">
<div class="eh-settings-group">
<label class="eh-settings-label" for="eh-setting-interval">Auto-refresh interval (minutes)</label>
<input type="number" id="eh-setting-interval" class="eh-settings-input" min="5">
<div class="eh-settings-description">This value will determine how many minutes to wait between automatic refreshes (Minimum: 5 minutes).</div>
</div>
<div class="eh-settings-group">
<label class="eh-settings-label" for="eh-setting-username">Username</label>
<input type="text" id="eh-setting-username" class="eh-settings-input" placeholder="Optional">
<div class="eh-settings-description">Enter your username to avoid receiving alerts for your own comments.</div>
</div>
<div class="eh-settings-group" style="margin-top: 5px;">
<label class="eh-settings-checkbox-label">
<input type="checkbox" id="eh-setting-edits" class="eh-settings-checkbox">
Also notify me when existing comments are edited.
</label>
</div>
<div class="eh-settings-group">
<label class="eh-settings-checkbox-label">
<input type="checkbox" id="eh-setting-ignore-uploader" class="eh-settings-checkbox">
Ignore uploader comment
</label>
</div>
${_GM_notification ? `
<div class="eh-settings-group">
<label class="eh-settings-checkbox-label">
<input type="checkbox" id="eh-setting-notif" class="eh-settings-checkbox">
Enable browser desktop notifications.
</label>
</div>` : ''}
<div class="eh-settings-group">
<label class="eh-settings-checkbox-label">
<input type="checkbox" id="eh-setting-newtab" class="eh-settings-checkbox">
Open links in new tab.
</label>
</div>
<div class="eh-settings-group" style="margin-top: 5px;">
<label class="eh-settings-label">Watchlist Backup</label>
<div style="display: flex; flex-direction: column; gap: 6px;">
<button class="eh-btn" id="eh-export-btn">📤 Export Watchlist</button>
<button class="eh-btn" id="eh-import-replace-btn">📥 Import (Replace)</button>
<button class="eh-btn" id="eh-import-merge-btn">📥 Import (Merge)</button>
</div>
</div>
</div>
`;
panel.appendChild(settingsModal);
// Prevent settings clicks from bubbling up and closing the panel or triggering watch panel events
settingsModal.onclick = (e) => {
e.stopPropagation();
};
const backBtn = settingsModal.querySelector('.eh-settings-back');
if (backBtn) {
backBtn.onclick = (e) => {
e.stopPropagation();
e.preventDefault();
settingsModal.classList.remove('open');
panel.classList.remove('settings-active');
};
}
const intervalInput = settingsModal.querySelector('#eh-setting-interval');
if (intervalInput) {
intervalInput.onchange = async () => {
let val = parseInt(intervalInput.value, 10);
if (isNaN(val) || val < 5) {
val = 30;
intervalInput.value = 30;
}
await updateSetting('refreshInterval', val);
};
}
const usernameInput = settingsModal.querySelector('#eh-setting-username');
if (usernameInput) {
usernameInput.oninput = async () => {
await updateSetting('username', usernameInput.value.trim());
};
}
const editsCheckbox = settingsModal.querySelector('#eh-setting-edits');
if (editsCheckbox) {
editsCheckbox.onchange = async () => {
await updateSetting('alertOnEdits', editsCheckbox.checked);
};
}
const ignoreUploaderCheckbox = settingsModal.querySelector('#eh-setting-ignore-uploader');
if (ignoreUploaderCheckbox) {
ignoreUploaderCheckbox.onchange = async () => {
await updateSetting('ignoreUploaderComment', ignoreUploaderCheckbox.checked);
};
}
const newTabCheckbox = settingsModal.querySelector('#eh-setting-newtab');
if (newTabCheckbox) {
newTabCheckbox.onchange = async () => {
const enabled = newTabCheckbox.checked;
await updateSetting('openInNewTab', enabled);
applyOpenInNewTab(enabled);
};
}
const notifCheckbox = settingsModal.querySelector('#eh-setting-notif');
if (notifCheckbox) {
notifCheckbox.onchange = async () => {
await updateSetting('enableNotifications', notifCheckbox.checked);
};
}
const exportBtn = settingsModal.querySelector('#eh-export-btn');
if (exportBtn) {
exportBtn.onclick = async (e) => {
e.stopPropagation();
await exportWatchlist();
};
}
const importReplaceBtn = settingsModal.querySelector('#eh-import-replace-btn');
if (importReplaceBtn) {
importReplaceBtn.onclick = (e) => {
e.stopPropagation();
const confirmed = window.confirm(
'Import (Replace)\n\n' +
'WARNING: Your entire current watchlist will be permanently replaced by the imported file.\n' +
'All existing watched galleries and their stored state will be lost.\n' +
'This action cannot be undone.\n\n' +
'Continue?'
);
if (!confirmed) return;
triggerFilePicker((file) => importWatchlist(file, 'replace'));
};
}
const importMergeBtn = settingsModal.querySelector('#eh-import-merge-btn');
if (importMergeBtn) {
importMergeBtn.onclick = (e) => {
e.stopPropagation();
const confirmed = window.confirm(
'Import (Merge)\n\n' +
'Galleries from the imported file will be added to your existing watchlist.\n' +
'Galleries whose ID already exists in your watchlist will be skipped.\n' +
'No existing gallery will be removed or modified.\n\n' +
'Continue?'
);
if (!confirmed) return;
triggerFilePicker((file) => importWatchlist(file, 'merge'));
};
}
badge.onclick = (e) => {
e.stopPropagation();
panel.classList.toggle('open');
if (panel.classList.contains('open')) {
if (_needsRender) {
const itemsContainer = document.getElementById('eh-items-container');
if (itemsContainer && !itemsContainer.innerHTML.trim()) {
itemsContainer.innerHTML = '<div style="padding: 20px; text-align: center; color: #888;">Loading...</div>';
}
renderPanel();
} else {
_GM_getValue(LAST_CHECK_KEY, 0).then(lastCheck => {
const lastCheckStr = lastCheck ? new Date(lastCheck).toLocaleTimeString() : 'Never';
const timeEl = document.getElementById('eh-last-checked');
if (timeEl) timeEl.textContent = `Last checked: ${lastCheckStr}`;
});
}
} else {
settingsModal.classList.remove('open');
panel.classList.remove('settings-active');
}
};
// Prevent clicks inside the panel from bubbling to the document-level close handler.
// Without this, Chrome+Tampermonkey's sandbox causes panel.contains(e.target) to
// return false for dynamically rendered buttons, closing the panel on every click.
panel.addEventListener('click', (e) => e.stopPropagation());
// Close on click outside
document.addEventListener('click', (e) => {
if (panel.classList.contains('open') && !badge.contains(e.target)) {
panel.classList.remove('open');
settingsModal.classList.remove('open');
panel.classList.remove('settings-active');
}
});
return badge;
}
function applyOpenInNewTab(enabled) {
document.querySelectorAll('.eh-open-title').forEach(a => {
if (enabled) {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
} else {
a.removeAttribute('target');
a.removeAttribute('rel');
}
});
}
// --- Import / Export ---
function validateImportFile(parsed) {
if (!parsed || typeof parsed !== 'object') {
return { valid: false, reason: 'File is not a valid JSON object.', userMessage: 'The selected file is not a valid JSON file.' };
}
if (parsed.version !== 1) {
return { valid: false, reason: `Unknown format version: ${parsed.version}. Expected version 1.`, userMessage: 'This file is not a valid watchlist export.' };
}
if (!Array.isArray(parsed.galleries)) {
return { valid: false, reason: 'Missing or invalid \'galleries\' array.', userMessage: 'This file is not a valid watchlist export.' };
}
if (parsed.galleries.length === 0) {
return { valid: false, reason: 'The file contains no galleries.', userMessage: 'This file does not contain any galleries.' };
}
for (const g of parsed.galleries) {
if (!g.id || !g.url || !g.title) {
return { valid: false, reason: `One or more galleries are missing required fields (id, url, title).`, userMessage: 'This file contains invalid or corrupted data.' };
}
if (!g.url.startsWith('https://e-hentai.org/') && !g.url.startsWith('https://exhentai.org/')) {
return { valid: false, reason: `Invalid gallery URL: ${g.url}`, userMessage: 'This file contains invalid or corrupted data.' };
}
}
return { valid: true };
}
async function exportWatchlist() {
const state = await getState();
const galleries = Object.values(state.watched || {});
if (galleries.length === 0) {
alert('Your watchlist is empty. Nothing to export.');
return;
}
const payload = {
version: 1,
exportedAt: new Date().toISOString(),
galleries
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const dateStr = new Date().toISOString().substring(0, 10);
const a = document.createElement('a');
a.href = url;
a.download = `eh-comment-watcher-export-${dateStr}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async function importWatchlist(file, mode) {
let parsed;
try {
const text = await file.text();
parsed = JSON.parse(text);
} catch (e) {
console.log(`[EH-CW] Import failed: JSON parse error or file read error.`);
alert('The selected file is not a valid JSON file.');
return;
}
const validation = validateImportFile(parsed);
if (!validation.valid) {
console.log(`[EH-CW] Import failed: ${validation.reason}`);
alert(validation.userMessage || 'Import failed: invalid file.');
return;
}
const state = await getState();
if (!state.watched) state.watched = {};
if (mode === 'replace') {
state.watched = {};
for (const g of parsed.galleries) {
state.watched[g.id] = g;
}
await saveState(state);
await updateUI(state);
alert(`Imported ${parsed.galleries.length} galleries.`);
} else { // merge
let imported = 0;
let skipped = 0;
for (const g of parsed.galleries) {
if (state.watched[g.id]) {
skipped++;
} else {
state.watched[g.id] = g;
imported++;
}
}
await saveState(state);
await updateUI(state);
alert(`Imported ${imported} ${imported === 1 ? 'gallery' : 'galleries'}, skipped ${skipped} ${skipped === 1 ? 'duplicate' : 'duplicates'}.`);
}
}
function triggerFilePicker(callback) {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,application/json';
input.style.display = 'none';
document.body.appendChild(input);
input.onchange = () => {
const file = input.files[0];
document.body.removeChild(input);
if (file) callback(file);
};
input.click();
}
async function renderPanel(state) {
const itemsContainer = document.getElementById('eh-items-container');
if (!itemsContainer) return;
if (!state) state = await getState();
const lastCheck = await _GM_getValue(LAST_CHECK_KEY, 0);
const lastCheckStr = lastCheck ? new Date(lastCheck).toLocaleTimeString() : 'Never';
const timeEl = document.getElementById('eh-last-checked');
if (timeEl) timeEl.textContent = `Last checked: ${lastCheckStr}`;
const settings = await getSettings();
const alertOnEdits = settings.alertOnEdits;
const watchedList = Object.values(state.watched || {}).sort((a, b) => {
const aTime = alertOnEdits ? Math.max(a.lastTimestamp || 0, a.lastEditTimestamp || 0) : (a.lastTimestamp || 0);
const bTime = alertOnEdits ? Math.max(b.lastTimestamp || 0, b.lastEditTimestamp || 0) : (b.lastTimestamp || 0);
return bTime - aTime;
});
let html = '';
if (watchedList.length === 0) {
html = `<p style="font-size: 12px; text-align: center; color: #888; padding: 20px;">No galleries watched yet.</p>`;
} else {
const linkTarget = settings.openInNewTab !== false ? '_blank' : '_self';
watchedList.forEach(g => {
const targetUrl = (g.hasNew && g.oldestUnreadId) ? `${g.url}#${g.oldestUnreadId}` : g.url;
html += `
<div class="eh-item ${g.hasNew ? 'has-new' : ''}" data-id="${g.id}">
<div class="eh-item-main">
<input type="checkbox" class="eh-checkbox item-select" data-id="${g.id}">
<div class="eh-item-content">
<a href="${targetUrl}" class="eh-title eh-open-title${g.unavailable ? ' eh-title-unavailable' : ''}" data-id="${g.id}" target="${linkTarget}">${escapeHtml(g.title)}</a>
<div class="eh-meta">
${g.unavailable
? 'Gallery Unavailable'
: g.hasNew ? (() => {
const parts = [];
if (g.newComments > 0) parts.push(`${g.newComments} new comment${g.newComments === 1 ? '' : 's'}`);
if (g.newEdits > 0) parts.push(`${g.newEdits} new edit${g.newEdits === 1 ? '' : 's'}`);
return `<b>${parts.join(', ')}</b>`;
})() : 'No new comments'}<br>
${g.unavailable ? 'Last: N/A' : (() => {
const latestActivityTs = Math.max(g.lastTimestamp || 0, g.lastEditTimestamp || 0);
const isEdit = (g.lastEditTimestamp || 0) > (g.lastTimestamp || 0);
const tsStr = latestActivityTs ? `<span style="font-size: 0.9em; opacity: 0.7;">(${new Date(latestActivityTs).toISOString().replace('T', ' ').substring(0, 16)}${isEdit ? ', Edit' : ''})</span>` : '';
return `Last: ${escapeHtml(g.lastCommenter) || 'N/A'} ${tsStr}`;
})()}
</div>
</div>
</div>
<div class="eh-actions">
${g.hasNew ? `<button class="eh-btn read" data-id="${g.id}">Mark Read</button>` : ''}
<button class="eh-btn unwatch" data-id="${g.id}">Unwatch</button>
</div>
</div>`;
});
}
itemsContainer.innerHTML = html;
_needsRender = false;
const selectAllCb = document.getElementById('eh-select-all');
if (selectAllCb) selectAllCb.checked = false;
}
async function markAsRead(id) {
const state = await getState();
if (state.watched && state.watched[id]) {
state.watched[id].hasNew = false;
state.watched[id].newComments = 0;
state.watched[id].newEdits = 0;
delete state.watched[id].oldestUnreadId;
await saveState(state);
await updateUI(state);
// Only update the watch button when actually on a gallery page
if (isGalleryPage()) await injectWatchButton();
}
}
async function runLoop() {
await checkGalleries();
setTimeout(runLoop, 60000);
}
async function init() {
// Skip execution entirely on unavailable pages (they auto-redirect anyway)
if (document.getElementById('continue')) {
console.log('[EH-CW] Unavailable gallery detected, skipping init.');
return;
}
console.log('[EH-CW] Initializing UI components...');
const refreshState = await _GM_getValue(REFRESH_STATE_KEY);
if (refreshState) {
_isRefreshing = true;
updateRefreshButton(true);
}
await injectWatchButton();
const state = await getState();
await updateUI(state);
if (isGalleryPage()) {
const currentId = getGalleryId(window.location.href);
if (currentId && state.watched && state.watched[currentId]) {
const g = state.watched[currentId];
// Automatic migration to ExHentai if previously unavailable
if (g.unavailable && window.location.hostname.includes('exhentai.org')) {
g.unavailable = false;
g.url = normalizeGalleryUrl(window.location.href);
await saveState(state);
await updateUI(state);
}
if (g.hasNew) {
await markAsRead(currentId);
}
}
}
runLoop();
}
init();
})();