// ==UserScript==
// @name Kohlchan Thread Watcher
// @namespace Violentmonkey Scripts
// @match https://kohlchan.net/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @connect kohlchan.net
// @version 1.0
// @license MIT License
// @icon https://kohlchan.net/favicon.ico
// @author Bernd
// @description Monitors Kohlchan threads for new posts.
// ==/UserScript==
(function() {
'use strict';
// --- State Management ---
let watched = [];
let channel;
let isWatcherOpen = GM_getValue('is_watcher_open', false);
function loadWatched() {
watched = GM_getValue('watched_threads', []);
// Migration from old string array format to new object format
if (watched.length > 0 && typeof watched[0] === 'string') {
console.log('Kohlchan Watcher: Migrating old watched threads format.');
watched = watched.map(url => ({ url: url, subject: '[Subject not loaded]', lastReplies: 0, unread: 0 }));
saveWatched();
}
// Deduplicate watched threads by URL
watched = watched.filter((item, index, arr) => item.url && arr.findIndex(i => i.url === item.url) === index);
}
// --- Core Logic ---
function saveWatched() {
// Deduplicate before saving
watched = watched.filter((item, index, arr) => item.url && arr.findIndex(i => i.url === item.url) === index);
GM_setValue('watched_threads', watched);
if (channel) {
channel.postMessage({ action: 'update' });
}
}
function addCacheBuster(url) {
const timestamp = Date.now();
return url + (url.includes('?') ? '&' : '?') + '_=' + timestamp;
}
/**
* Fetches the HTML of a thread and parses out the subject or a message snippet and total posts.
* @param {string} url The URL of the thread to fetch.
* @returns {Promise<{subject: string, replies: number}>} The subject and reply count.
*/
function getThreadInfo(url) {
const bustUrl = addCacheBuster(url);
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: "GET",
url: bustUrl,
onload: function(response) {
if (response.status < 200 || response.status >= 300) {
resolve({ subject: `[HTTP Error ${response.status}]`, replies: 0 });
return;
}
try {
const parser = new DOMParser();
const doc = parser.parseFromString(response.responseText, 'text/html');
// Count total posts: OP + replies
const postCells = doc.querySelectorAll('.postCell');
const totalPosts = 1 + postCells.length;
// Look for subject in the labelSubject element
const subjectEl = doc.querySelector('.innerOP .labelSubject');
let subject = subjectEl ? subjectEl.textContent.trim() : '';
// If no subject found, try to get a snippet from the first message
if (!subject) {
const messageEl = doc.querySelector('.innerOP .divMessage');
if (messageEl) {
subject = messageEl.textContent
.replace(/\n/g, ' ')
.replace(/\s{2,}/g, ' ')
.trim();
// Truncate long messages
if (subject.length > 30) {
subject = subject.substring(0, 30) + '...';
}
}
}
// If still no subject, use a default message
resolve({ subject: subject || "[No Subject]", replies: totalPosts });
} catch (e) {
console.error("Parsing error for", url, e);
resolve({ subject: "[Parsing Error]", replies: 0 });
}
},
onerror: function() {
resolve({ subject: "[Network Error]", replies: 0 });
}
});
});
}
/**
* Fetches the reply count for a thread.
* @param {string} url The URL of the thread.
* @returns {Promise<{replies: number, isDead: boolean}>} The total number of posts and whether the thread is dead.
*/
function getThreadReplyCount(url) {
const bustUrl = addCacheBuster(url);
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: "GET",
url: bustUrl,
onload: function(response) {
if (response.status === 404) {
resolve({ replies: 0, isDead: true });
return;
}
if (response.status < 200 || response.status >= 300) {
resolve({ replies: 0, isDead: false });
return;
}
try {
const parser = new DOMParser();
const doc = parser.parseFromString(response.responseText, 'text/html');
const postCells = doc.querySelectorAll('.postCell');
const totalPosts = 1 + postCells.length;
resolve({ replies: totalPosts, isDead: false });
} catch (e) {
console.error("Parsing error for reply count", url, e);
resolve({ replies: 0, isDead: false });
}
},
onerror: function() {
resolve({ replies: 0, isDead: false });
}
});
});
}
async function updateUnreadCounts() {
loadWatched();
const cleanUrl = location.href.split('#')[0];
const isCurrentPage = /\/res\/\d+\.html$/.test(location.pathname);
const currentItem = watched.find(item => item.url === cleanUrl);
if (currentItem && isCurrentPage) {
const postCells = document.querySelectorAll('.postCell');
currentItem.lastReplies = 1 + postCells.length;
currentItem.unread = 0;
}
const otherItems = watched.filter(item => item !== currentItem);
const deadUrls = [];
const promises = otherItems.map(async (item) => {
const { replies, isDead } = await getThreadReplyCount(item.url);
if (isDead) {
deadUrls.push(item.url);
} else if (replies > 0) {
if (item.lastReplies === 0) {
item.lastReplies = replies;
item.unread = 0;
} else {
item.unread = Math.max(0, replies - item.lastReplies);
}
}
});
await Promise.all(promises);
deadUrls.forEach(url => {
watched = watched.filter(i => i.url !== url);
});
saveWatched();
renderWatcherList();
}
async function addToWatched(url) {
loadWatched();
// 1. Strip hash from URL
const cleanUrl = url.split('#')[0];
// Basic URL validation
if (!cleanUrl || !cleanUrl.startsWith('http') || !cleanUrl.includes('/res/')) {
console.warn('Invalid URL for watcher:', cleanUrl);
return;
}
// Check if already watched
if (watched.some(item => item.url === cleanUrl)) {
return;
}
const { subject, replies } = await getThreadInfo(cleanUrl);
if (replies === 0 && subject.includes('HTTP Error 404')) {
console.warn('Attempted to add dead thread:', cleanUrl);
return;
}
watched.push({ url: cleanUrl, subject: subject, lastReplies: replies, unread: 0 });
saveWatched();
renderWatcherList();
}
function removeFromWatched(url) {
loadWatched();
watched = watched.filter(item => item.url !== url);
saveWatched();
renderWatcherList();
}
function checkCurrentThread() {
loadWatched();
const cleanUrl = location.href.split('#')[0];
const item = watched.find(i => i.url === cleanUrl);
if (item && /\/res\/\d+\.html$/.test(location.pathname)) {
// Count posts from current page DOM instead of fetching
const postCells = document.querySelectorAll('.postCell');
const totalPosts = 1 + postCells.length;
if (totalPosts > 0) {
item.lastReplies = totalPosts;
item.unread = 0;
saveWatched();
renderWatcherList();
}
}
}
function syncOnVisibility() {
if (!document.hidden) {
loadWatched();
renderWatcherList();
}
}
// --- UI Rendering ---
function injectStyles() {
const styles = `
#watcher-toggle-button { cursor: pointer; text-decoration: none; }
#watcher-container { display: none; position: fixed; top: 41px; right: 15px; width: 270px; max-height: 50vh; z-index: 10000; border: 1px solid #3d3d5c; background: #222426; font-family: sans-serif; font-size: 10px; color: #ccc; flex-direction: column; }
#watcher-container.open { display: flex; }
#watcher-header { display: flex; justify-content: space-between; align-items: center; padding: 2px 4px; background: #27295a; border-bottom: 1px solid #3d3d5c; cursor: grab; user-select: none; }
#watcher-header:active { cursor: grabbing; }
#watcher-title { font-weight: bold; flex-grow: 1; text-align: center; }
#watcher-close-button { cursor: pointer; border: none; background: none; font-size: 16px; color: #ccc; padding: 0 5px; line-height: 1; }
#watcher-list { overflow-y: auto; padding: 5px; flex-grow: 1; }
.watcher-empty-message { text-align: center; padding: 10px; font-style: italic; }
.watcher-item { display: flex; justify-content: space-between; align-items: center; padding: 2px; border-bottom: 1px solid #3d3d5c; }
.watcher-item:last-child { border-bottom: none; }
.watcher-item-link { text-decoration: none; color: #aaccff; flex-grow: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.watcher-item-link:hover { text-decoration: underline; }
.watcher-item-remove { background: #444; border: 1px solid #555; color: #ccc; cursor: pointer; margin-left: 10px; padding: 0px 5px; }
#watcher-footer { padding: 5px; border-top: 1px solid #3d3d5c; display: flex; }
#watcher-manual-add-input { flex-grow: 1; border: 1px solid #3d3d5c; background: #1a1a1a; color: #ccc; font-size: 10px; padding: 2px; min-width: 0; cursor: text; caret-color: #ccc; }
`;
const styleSheet = document.createElement("style");
styleSheet.innerText = styles;
document.head.appendChild(styleSheet);
/*
// Light theme styles (uncomment and replace 'styles' with 'lightStyles' to enable)
const lightStyles = `
#watcher-toggle-button { cursor: pointer; text-decoration: none; }
#watcher-container { display: none; position: fixed; top: 41px; right: 15px; width: 270px; max-height: 50vh; z-index: 10000; border: 1px solid #ddd; background: #f5f5f5; font-family: sans-serif; font-size: 10px; color: #333; flex-direction: column; }
#watcher-container.open { display: flex; }
#watcher-header { display: flex; justify-content: space-between; align-items: center; padding: 2px 4px; background: #e0e0e0; border-bottom: 1px solid #ddd; cursor: grab; user-select: none; }
#watcher-header:active { cursor: grabbing; }
#watcher-title { font-weight: bold; flex-grow: 1; text-align: center; }
#watcher-close-button { cursor: pointer; border: none; background: none; font-size: 16px; color: #333; padding: 0 5px; line-height: 1; }
#watcher-list { overflow-y: auto; padding: 5px; flex-grow: 1; }
.watcher-empty-message { text-align: center; padding: 10px; font-style: italic; }
.watcher-item { display: flex; justify-content: space-between; align-items: center; padding: 2px; border-bottom: 1px solid #ddd; }
.watcher-item:last-child { border-bottom: none; }
.watcher-item-link { text-decoration: none; color: #229; flex-grow: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.watcher-item-link:hover { text-decoration: underline; }
.watcher-item-remove { background: #f0f0f0; border: 1px solid #ccc; color: #333; cursor: pointer; margin-left: 10px; padding: 0px 5px; }
#watcher-footer { padding: 5px; border-top: 1px solid #ddd; display: flex; }
#watcher-manual-add-input { flex-grow: 1; border: 1px solid #ddd; background: #fff; color: #333; font-size: 10px; padding: 2px; min-width: 0; cursor: text; caret-color: #333; }
`;
const lightStyleSheet = document.createElement("style");
lightStyleSheet.innerText = lightStyles;
document.head.appendChild(lightStyleSheet);
*/
}
function createWatcherUI() {
// Toggle Link
const toggleLink = document.createElement('a');
toggleLink.id = 'watcher-toggle-button';
toggleLink.textContent = '👁';
toggleLink.href = 'javascript:void(0);';
// Main Container
const container = document.createElement('div');
container.id = 'watcher-container';
if (isWatcherOpen) {
container.classList.add('open');
}
// Header
const header = document.createElement('div');
header.id = 'watcher-header';
const title = document.createElement('span');
title.id = 'watcher-title';
title.textContent = 'Watched Threads';
const closeButton = document.createElement('button');
closeButton.id = 'watcher-close-button';
closeButton.innerHTML = '×';
header.append(title, closeButton);
// List
const list = document.createElement('div');
list.id = 'watcher-list';
// Footer
const footer = document.createElement('div');
footer.id = 'watcher-footer';
const input = document.createElement('input');
input.id = 'watcher-manual-add-input';
input.type = 'text';
input.placeholder = 'Paste thread URL...';
footer.append(input);
container.append(header, list, footer);
document.body.append(container);
// Insert toggle before settings button in its parent
const settingsA = document.getElementById('settingsButton');
if (settingsA) {
const parent = settingsA.parentNode;
parent.insertBefore(toggleLink, settingsA);
const slashSpace = document.createTextNode(' / ');
parent.insertBefore(slashSpace, settingsA);
}
// Restore position if open
if (isWatcherOpen) {
const savedPos = GM_getValue('watcher_position', null);
if (savedPos && savedPos.left !== undefined && savedPos.top !== undefined) {
container.style.left = `${savedPos.left}px`;
container.style.top = `${savedPos.top}px`;
container.style.right = 'auto';
container.style.bottom = 'auto';
}
}
// --- Add Event Listeners ---
const toggleUI = () => {
const wasOpen = isWatcherOpen;
isWatcherOpen = !isWatcherOpen;
if (isWatcherOpen) {
// Reset to default position when opening
container.style.left = 'auto';
container.style.top = '41px';
container.style.right = '15px';
container.style.bottom = 'auto';
GM_deleteValue('watcher_position');
container.classList.add('open');
} else {
container.classList.remove('open');
}
GM_setValue('is_watcher_open', isWatcherOpen);
};
toggleLink.addEventListener('click', (e) => { e.preventDefault(); toggleUI(); });
closeButton.addEventListener('click', toggleUI);
// Keyboard shortcut: 't' to toggle watcher visibility
document.addEventListener('keydown', (e) => {
if (e.key === 't' && !e.target.matches('input, textarea')) {
e.preventDefault();
toggleUI();
}
});
input.addEventListener('paste', async () => {
setTimeout(async () => {
const value = input.value.trim();
if (value) {
await addToWatched(value);
input.value = '';
}
}, 0);
});
// Drag and drop support for input
input.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
input.addEventListener('drop', async (e) => {
e.preventDefault();
const data = e.dataTransfer.getData('text/plain').trim();
if (data) {
await addToWatched(data);
input.value = '';
}
});
// Drag and drop support for list
list.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
list.addEventListener('drop', async (e) => {
e.preventDefault();
const data = e.dataTransfer.getData('text/plain').trim();
if (data) {
await addToWatched(data);
input.value = '';
}
});
// Drag functionality
let isDragging = false;
let offsetX, offsetY;
header.addEventListener('mousedown', (e) => {
isDragging = true;
offsetX = e.clientX - container.offsetLeft;
offsetY = e.clientY - container.offsetTop;
header.style.cursor = 'grabbing';
});
document.addEventListener('mousemove', (e) => {
if (isDragging) {
const newLeft = Math.max(0, Math.min(e.clientX - offsetX, window.innerWidth - container.offsetWidth));
const newTop = Math.max(0, Math.min(e.clientY - offsetY, window.innerHeight - container.offsetHeight));
container.style.left = `${newLeft}px`;
container.style.top = `${newTop}px`;
// unset right/bottom to allow free movement
container.style.right = 'auto';
container.style.bottom = 'auto';
}
});
document.addEventListener('mouseup', () => {
if (isDragging) {
const pos = {
left: container.offsetLeft,
top: container.offsetTop
};
GM_setValue('watcher_position', pos);
isDragging = false;
}
header.style.cursor = 'grab';
});
}
function renderWatcherList() {
const listContainer = document.getElementById('watcher-list');
if (!listContainer) return;
listContainer.innerHTML = ''; // Clear previous list
if (watched.length === 0) {
listContainer.innerHTML = `<div class="watcher-empty-message">No threads watched.</div>`;
return;
}
watched.forEach(item => {
const { url, subject, unread } = item;
let parts, board, threadId;
try {
parts = new URL(url).pathname.split('/');
board = parts[1];
threadId = parts[3].replace('.html', '');
} catch (e) {
console.error("Could not parse URL for watcher item:", url);
board = "invalid";
threadId = "url";
}
const itemEl = document.createElement('div');
itemEl.className = 'watcher-item';
const link = document.createElement('a');
link.className = 'watcher-item-link';
link.href = url;
link.textContent = `(${unread}) /${board}/ #${threadId} - ${subject}`;
link.title = `${url}\n${subject}`;
const removeBtn = document.createElement('button');
removeBtn.className = 'watcher-item-remove';
removeBtn.textContent = 'x';
removeBtn.title = 'Remove';
removeBtn.addEventListener('click', () => removeFromWatched(url));
itemEl.append(link, removeBtn);
listContainer.appendChild(itemEl);
});
}
// --- Kohlchan Integration ---
function setupPostListeners() {
const handlePostClick = () => {
const cleanUrl = location.href.split('#')[0];
if (/\/res\/\d+\.html$/.test(location.pathname)) {
// Reply in existing thread
GM_setValue('pending_reply_watch', cleanUrl);
setTimeout(async () => {
const pending = GM_getValue('pending_reply_watch');
const currentClean = location.href.split('#')[0];
if (pending === currentClean) {
let itemIndex = watched.findIndex(i => i.url === pending);
if (itemIndex === -1) {
const { subject, replies } = await getThreadInfo(pending);
if (replies === 0 && subject.includes('HTTP Error 404')) {
console.warn('Attempted to add dead thread after reply:', pending);
GM_deleteValue('pending_reply_watch');
return;
}
watched.push({ url: pending, subject, lastReplies: replies, unread: 0 });
} else {
const { replies } = await getThreadReplyCount(pending);
watched[itemIndex].lastReplies = replies;
watched[itemIndex].unread = 0;
}
saveWatched();
renderWatcherList();
GM_deleteValue('pending_reply_watch');
}
}, 2000);
} else if (location.pathname.endsWith('.html') && !location.pathname.includes('/res/')) {
// Starting new thread on board
GM_setValue('just_started_thread', true);
}
};
const formButton = document.getElementById('formButton');
if (formButton) {
formButton.addEventListener('click', handlePostClick);
}
const qrButton = document.getElementById('qrbutton');
if (qrButton) {
qrButton.addEventListener('click', handlePostClick);
}
}
async function checkForNewPost() {
if (/\/res\/\d+\.html$/.test(location.pathname)) {
const cleanUrl = location.href.split('#')[0];
const pending = GM_getValue('pending_reply_watch', null);
if (pending && pending === cleanUrl) {
loadWatched();
let itemIndex = watched.findIndex(i => i.url === cleanUrl);
if (itemIndex === -1) {
const { subject, replies } = await getThreadInfo(cleanUrl);
if (replies === 0 && subject.includes('HTTP Error 404')) {
console.warn('Attempted to add dead thread after reply:', cleanUrl);
GM_deleteValue('pending_reply_watch');
return;
}
watched.push({ url: cleanUrl, subject, lastReplies: replies, unread: 0 });
} else {
const { replies, isDead } = await getThreadReplyCount(cleanUrl);
if (isDead) {
watched = watched.filter(i => i.url !== cleanUrl);
saveWatched();
renderWatcherList();
GM_deleteValue('pending_reply_watch');
return;
}
watched[itemIndex].lastReplies = replies;
watched[itemIndex].unread = 0;
}
saveWatched();
renderWatcherList();
GM_deleteValue('pending_reply_watch');
} else {
const justStarted = GM_getValue('just_started_thread', false);
if (justStarted) {
loadWatched();
const { subject, replies } = await getThreadInfo(cleanUrl);
if (replies === 0 && subject.includes('HTTP Error 404')) {
console.warn('Attempted to add dead thread after starting:', cleanUrl);
GM_deleteValue('just_started_thread');
return;
}
if (!watched.some(i => i.url === cleanUrl)) {
watched.push({ url: cleanUrl, subject, lastReplies: replies, unread: 0 });
saveWatched();
renderWatcherList();
}
GM_deleteValue('just_started_thread');
}
}
}
}
// --- Initialization ---
function init() {
// Ignore the sidebar iframe
if (location.pathname === '/.static/pages/sidebar.html') {
return;
}
channel = new BroadcastChannel('kohlchan-watcher');
channel.addEventListener('message', (e) => {
if (e.data.action === 'update') {
loadWatched();
renderWatcherList();
}
});
loadWatched();
injectStyles();
createWatcherUI();
renderWatcherList();
checkForNewPost().catch(console.error);
checkCurrentThread();
setupPostListeners();
updateUnreadCounts().catch(console.error); // Initial update for migrated threads
// Periodic update
setInterval(() => updateUnreadCounts().catch(console.error), 10000);
// Sync on tab visibility change
document.addEventListener('visibilitychange', syncOnVisibility);
// Observe for dynamically loaded elements like the QR form
const observer = new MutationObserver(setupPostListeners);
observer.observe(document.body, { childList: true, subtree: true });
}
// Run the script
init();
})();