Kohlchan Thread Watcher

Monitors Kohlchan threads for new posts.

// ==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 = '&times;';
        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();

})();