Logic Masters Puzzle Watcher

Watch favorite users and custom searches for new/unsolved puzzles on Logic Masters Deutschland

// ==UserScript==
// @name         Logic Masters Puzzle Watcher
// @namespace    http://tampermonkey.net/
// @version      3.0
// @description  Watch favorite users and custom searches for new/unsolved puzzles on Logic Masters Deutschland
// @author       Oliver Burgert
// @match        https://logic-masters.de/*
// @license      GPL-3.0-or-later
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// ==/UserScript==

(function() {
    'use strict';

    const CHECK_INTERVAL = 60 * 60 * 1000; // 1 hour

    const STORAGE_KEYS = {
        FAVORITE_USERS: 'lm_favorite_users',
        FAVORITE_SEARCHES: 'lm_favorite_searches',
        LAST_CHECK: 'lm_last_check',
        PUZZLE_DATA: 'lm_puzzle_data'
    };

    // ---------- Storage helpers ----------
    function initStorage() {
        if (!GM_getValue(STORAGE_KEYS.FAVORITE_USERS)) GM_setValue(STORAGE_KEYS.FAVORITE_USERS, JSON.stringify([]));
        if (!GM_getValue(STORAGE_KEYS.FAVORITE_SEARCHES)) GM_setValue(STORAGE_KEYS.FAVORITE_SEARCHES, JSON.stringify([]));
        if (!GM_getValue(STORAGE_KEYS.PUZZLE_DATA)) GM_setValue(STORAGE_KEYS.PUZZLE_DATA, JSON.stringify({}));
    }

    function getFavoriteUsers() {
        return JSON.parse(GM_getValue(STORAGE_KEYS.FAVORITE_USERS, '[]'));
    }

    function saveFavoriteUsers(users) {
        GM_setValue(STORAGE_KEYS.FAVORITE_USERS, JSON.stringify(users));
    }

    function getFavoriteSearches() {
        return JSON.parse(GM_getValue(STORAGE_KEYS.FAVORITE_SEARCHES, '[]'));
    }

    function saveFavoriteSearches(searches) {
        GM_setValue(STORAGE_KEYS.FAVORITE_SEARCHES, JSON.stringify(searches));
    }

    function getPuzzleData() {
        return JSON.parse(GM_getValue(STORAGE_KEYS.PUZZLE_DATA, '{}'));
    }

    function savePuzzleData(data) {
        GM_setValue(STORAGE_KEYS.PUZZLE_DATA, JSON.stringify(data));
    }

    function getLastCheck() {
        return GM_getValue(STORAGE_KEYS.LAST_CHECK, 0);
    }

    function saveLastCheck(ts) {
        GM_setValue(STORAGE_KEYS.LAST_CHECK, ts);
    }

    function shouldCheck() {
        return Date.now() - getLastCheck() >= CHECK_INTERVAL;
    }

    // ---------- Data fetching ----------
    function parsePuzzleData(html) {
        const parser = new DOMParser();
        const doc = parser.parseFromString(html, 'text/html');
        const errorElement = doc.querySelector('p.rp_error');
        if (errorElement) throw new Error('No results');

        const puzzleRows = doc.querySelectorAll('table.rp_raetselliste tr');
        // console.log(puzzleRows)
        const puzzles = [];
        for (let i = 1; i < puzzleRows.length; i++) {
            const cells = puzzleRows[i].querySelectorAll('td');
            // console.log(cells)
            if (cells.length >= 4) {
                // console.log(cells[1].innerText)
                let image_index = 0; // Start with the default index for users
                // Check the computed style of the first cell for right alignment to detect free search resutls
                if (cells[0].style.textAlign === "right") {
                    image_index = 1;
                }
                const statusImg = cells[image_index + 0].querySelector('img');
                const puzzleLink = cells[image_index + 1].querySelector('a');
                const solvedCount = cells[image_index + 2].textContent.trim();
                const ratingCell = cells[image_index + 3];

                if (statusImg && puzzleLink) {
                    const status = statusImg.getAttribute('title');
                    const difficultyImg = ratingCell.querySelector('img');
                    const ratingSpan = ratingCell.querySelector('span');

                    const descriptionSpan = cells[image_index + 1].querySelector('span');
                    const descriptionText = descriptionSpan ? descriptionSpan.textContent.trim().toLowerCase() : '';
                    const isSolved = /gelöst (am|heute|gestern)|solved (on|today|yesterday)/.test(descriptionText);
                    // console.log(descriptionText, isSolved)

                    if (!isSolved) {
                        puzzles.push({
                            name: puzzleLink.textContent.trim(),
                            link: puzzleLink.getAttribute('href'),
                            status,
                            solved: solvedCount,
                            difficulty: difficultyImg ? difficultyImg.getAttribute('alt') : '?',
                            difficultyTitle: difficultyImg ? difficultyImg.getAttribute('title') : '',
                            rating: ratingSpan ? ratingSpan.textContent.trim() : 'N/A'
                        });
                    }
                }
            }
        }
        return puzzles;
    }

    function fetchUserPuzzles(username) {
        const url = `https://logic-masters.de/Raetselportal/Benutzer/eingestellt.php?name=${encodeURIComponent(username)}`;
        return fetchPuzzlesGeneric('user:' + username, url);
    }

    function fetchSearchPuzzles(shortName, searchString) {
        const url = `https://logic-masters.de/Raetselportal/Suche/erweitert.php?${searchString}`;
        return fetchPuzzlesGeneric('search:' + shortName, url);
    }

    function fetchPuzzlesGeneric(key, url) {
        //console.log('fetching Key:', key, url);
        return new Promise(resolve => {
            GM_xmlhttpRequest({
                method: 'GET',
                url,
                onload: response => {
                    if (response.status === 200) {
                        try {
                            const puzzles = parsePuzzleData(response.responseText);
                            //console.log('parsing result:', puzzles)
                            resolve({ key, puzzles, error: null });
                        } catch (e) {
                            resolve({ key, puzzles: [], error: 'Failed to parse puzzle data' });
                        }
                    } else {
                        resolve({ key, puzzles: [], error: 'Request failed' });
                    }
                },
                onerror: () => resolve({ key, puzzles: [], error: 'Network error' })
            });
        });
    }

    async function updatePuzzleData() {
        const users = getFavoriteUsers();
        const searches = getFavoriteSearches();
        const puzzleData = {};

        const requests = [
            ...users.map(u => fetchUserPuzzles(u)),
            ...searches.map(s => fetchSearchPuzzles(s.shortName, s.searchString))
        ];

        const results = await Promise.all(requests);
        results.forEach(r => {
            puzzleData[r.key] = {
                puzzles: r.puzzles,
                error: r.error,
                lastUpdate: Date.now()
            };
        });
        savePuzzleData(puzzleData);
        saveLastCheck(Date.now());
        return puzzleData;
    }

    // ---------- UI ----------
    function createWidget() {
        const w = document.createElement('div');
        w.className = 'box menu';
        w.id = 'puzzle-watcher-widget';
        w.innerHTML = `
            <h2>Puzzle Watcher</h2>
            <div style="margin-bottom:10px;display:flex;align-items:center;gap:5px;flex-wrap:wrap;">
                <input id="new-user-input" placeholder="Username" style="width:100px;">
                <button id="add-user-btn" style="font-size:11px;">Add User</button>
                <button id="refresh-btn" style="font-size:11px;">Refresh</button>
            </div>
            <div style="margin-bottom:10px;display:flex;align-items:center;gap:5px;flex-wrap:wrap;">
                <input id="new-search-shortname" placeholder="Short name" style="width:90px;">
                <input id="new-search-string" placeholder="Search string" style="width:150px;">
                <button id="add-search-btn" style="font-size:11px;">Add Search</button>
            </div>
            <div id="puzzle-results"></div>`;
        return w;
    }

    function updateWidgetDisplay() {
        const resultsDiv = document.getElementById('puzzle-results');
        if (!resultsDiv) return;

        const users = getFavoriteUsers();
        const searches = getFavoriteSearches();
        const data = getPuzzleData();
        let html = '';

        if (users.length === 0 && searches.length === 0) {
            html = '<p style="font-size:11px;">No users or searches being watched.</p>';
        } else {
            // Users
            users.forEach(u => html += buildEntryHTML('user', u, data['user:' + u], `/Raetselportal/Benutzer/eingestellt.php?name=${encodeURIComponent(u)}`));
            // Searches
            searches.forEach(s => html += buildEntryHTML('search', s.shortName, data['search:' + s.shortName], `/Raetselportal/Suche/erweitert.php?${s.searchString}`));
        }

        resultsDiv.innerHTML = html;

        resultsDiv.querySelectorAll('.remove-btn').forEach(btn => {
            btn.addEventListener('click', function() {
                const type = this.dataset.type, key = this.dataset.key;
                if (type === 'user') removeUser(key);
                else removeSearch(key);
            });
        });
    }

    function buildEntryHTML(type, label, entryData, link) {
        let html = `<div style="border-bottom:1px solid #ccc;margin-bottom:5px;padding:3px 0;">`;
        html += `<div style="display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:bold;">`;
        html += `<a href="${link}" style="text-decoration:none;">${label}</a>`;
        html += `<button class="remove-btn" data-type="${type}" data-key="${label}" style="font-size:9px;">Remove</button></div>`;
        if (!entryData) html += `<p style="font-size:10px;color:#666;">Not checked yet...</p>`;
        else if (entryData.error) html += `<p style="font-size:10px;color:#f00;">${entryData.error}</p>`;
        else if (entryData.puzzles.length === 0) html += `<p style="font-size:10px;color:#666;">No new puzzles</p>`;
        else entryData.puzzles.forEach(p => {
            html += `<div style="margin:2px 0 2px 15px;font-size:10px;display: flex; align-items: baseline; gap: 4px; flex-wrap: wrap;">`;
            html += `<a href="${p.link}" style="text-decoration:none;">${p.name}</a>`;
            html += `<span style="color:#666;font-size:9px;"> (Level ${p.difficulty}, ${p.rating}, ${p.solved} solved)</span>`;
            html += `</div>`;
        });
        html += `</div>`;
        return html;
    }

    // ---------- User / Search management ----------
    function addUser() {
        const inp = document.getElementById('new-user-input');
        const val = inp.value.trim();
        if (!val) return;
        const users = getFavoriteUsers();
        if (!users.includes(val)) {
            users.push(val);
            saveFavoriteUsers(users);
            refreshData();
            updateWidgetDisplay();
        }
        inp.value = '';
    }

    function removeUser(username) {
        const users = getFavoriteUsers().filter(u => u !== username);
        saveFavoriteUsers(users);
        const data = getPuzzleData();
        delete data['user:' + username];
        savePuzzleData(data);
        updateWidgetDisplay();
    }

    function addSearch() {
        const sn = document.getElementById('new-search-shortname').value.trim();
        const ss = document.getElementById('new-search-string').value.trim();
        if (!sn || !ss) return alert('Both short name and search string are required.');
        const searches = getFavoriteSearches();
        if (!searches.some(s => s.shortName === sn)) {
            searches.push({ shortName: sn, searchString: ss });
            saveFavoriteSearches(searches);
            refreshData();
            updateWidgetDisplay();
        }
        document.getElementById('new-search-shortname').value = '';
        document.getElementById('new-search-string').value = '';
    }

    function removeSearch(shortName) {
        const searches = getFavoriteSearches().filter(s => s.shortName !== shortName);
        saveFavoriteSearches(searches);
        const data = getPuzzleData();
        delete data['search:' + shortName];
        savePuzzleData(data);
        updateWidgetDisplay();
    }

    // ---------- Refresh ----------
    async function refreshData() {
        const btn = document.getElementById('refresh-btn');
        if (btn) { btn.textContent = 'Loading...'; btn.disabled = true; }
        try { await updatePuzzleData(); updateWidgetDisplay(); }
        finally { if (btn) { btn.textContent = 'Refresh'; btn.disabled = false; } }
    }

    // ---------- Init ----------
    function init() {
        initStorage();
        const col = document.querySelector('.leftcolumn');
        if (!col) return;
        const widget = createWidget();
        col.appendChild(widget);

        document.getElementById('add-user-btn').addEventListener('click', addUser);
        document.getElementById('add-search-btn').addEventListener('click', addSearch);
        document.getElementById('refresh-btn').addEventListener('click', refreshData);

        document.getElementById('new-user-input').addEventListener('keypress', e => { if (e.key === 'Enter') addUser(); });
        document.getElementById('new-search-string').addEventListener('keypress', e => { if (e.key === 'Enter') addSearch(); });

        updateWidgetDisplay();
        if (shouldCheck()) refreshData();
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
})();