LPSG Forums Post Tracker

Track threads, mark as read/unread, and monitor new posts on LPSG Forums

Size

23.9 KB

Version

1.1.2

Created

Oct 28, 2025

Updated

17 days ago

1// ==UserScript==
2// @name		LPSG Forums Post Tracker
3// @description		Track threads, mark as read/unread, and monitor new posts on LPSG Forums
4// @version		1.1.2
5// @match		https://*.lpsg.com/*
6// @icon		data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAADrUlEQVR4AbxXT0gUURh/M2vrn6xOq7SeAkPI/3jOSDbE8BAo5EGCoBY8SChBgqxnCVwqRG1ZOokH8SjZepAuHRRELJWO2aFolwoUl9LZnX6/0VlmZ5ydWddNvp/zve993/v93nsz+2Zkgb+5ubk38XhczYVQKLTX0tLyowDEDbXx1tbWB6AWciKReBQIBB6yoaysiD/DwyLZ2amBPmPsGxwcvNTV1VUF/6zwGWp9kiSVoy24AhE6h5GIOBwfF+mtLSFUVQN9xtjHnKGhIVFTU/MZ/scc2FRV9RP6DwBHk5EscZbKwoJtMvuYw4TJycllr9fbYYfy8vKOdDr9GrkpwNbIy06ugFAWF+nnhJ5TWVkZWFtb+2mDX8lk8o4syyEMdhlwNE1AenvbMVHPgfIbNskSbqw+7O0roNomxxLWBFii+QdIfh/iXqCUNyku7kwTINfXO2brOZjdjilZJ3+JuGtyjIM7XWhPgSjp7kZtbtNzcIMZb5gzkRuZZCop6egQJb29xniWzz7mMDeVSk2ddJK8D8vuduZ/UbcJKEDGuAVBtrzBoPCOjAi5oUEISdJAnzH2ieO/oN/v34VL8nz2nKTTkiRNoFZbelw1k30+XzQWi82yxVmWhcOiIhbTQJ8xFKrAY+YiTyd3O3MFqzTj8XhGcd1jPZAxroAIh8Mf+vv7xfz8vNjf3/+u94J0B0XPj46OrhVCXlZWNrq+vp7EuFha/DeYJoBtHEQiGo2Knp6esaqqKokAaX11dfUzw7Lns+fazEm+urrKmZPGgowAS092QF92t8+5K3JSuBGgk+e1504zJznhJKCo5E4CSH7ue05SI2xXAAdLXs85npYZt8vuKADkvRiwKHtuJKdvXoE0gvf+Fzm4jg8jOiegoLvw/YCTuX7Ucg1EQmM/Jq/+RoArgYutkXzqLHtuHtEsgP087YxHLmNmHOBnejnXL5y5wK5tFiDhfe4LBh/Aub9kV4T4FSzVRGNjYzv8gswsAG/kqrSxsfENp9cA9uGt3egQWQexkUJFWATohBCxe8HjKboIWwEUgiP0a7FFWATglau0tra2tKmp6SKBD5BEWlGeYs/fU9Rp0Lejubn5dltbWwXrzOCYGKPUXG8RgL1/go+PZQy6ROBDI4a9nkbhVSDrdQrtjCG3DphVFOUdrlqt8Xoy5hgKPEDGLALQcx1oR/FNHRBwC34d4pY3GsSM5kdeps7oI4lPDD9qssbQBCBRnxmv5woQnzaewFYzLv4BAAD//zTuLfEAAAAGSURBVAMAg78b2dG1anwAAAAASUVORK5CYII=
7// ==/UserScript==
8(function() {
9    'use strict';
10
11    console.log('LPSG Forums Post Tracker initialized');
12
13    // Utility function to debounce
14    function debounce(func, wait) {
15        let timeout;
16        return function executedFunction(...args) {
17            const later = () => {
18                clearTimeout(timeout);
19                func(...args);
20            };
21            clearTimeout(timeout);
22            timeout = setTimeout(later, wait);
23        };
24    }
25
26    // Initialize the tracker
27    async function init() {
28        console.log('Starting LPSG Post Tracker...');
29        
30        // Get tracked threads from storage
31        const trackedThreads = await GM.getValue('lpsg_tracked_threads', {});
32        console.log('Loaded tracked threads:', Object.keys(trackedThreads).length);
33
34        // Check if we're on a thread page and update attachment count
35        if (window.location.href.includes('/threads/')) {
36            await updateCurrentThreadAttachments();
37        }
38
39        // Add tracking buttons to all thread elements
40        addTrackingButtons();
41
42        // Observe DOM changes to handle dynamically loaded content
43        const observer = new MutationObserver(debounce(() => {
44            addTrackingButtons();
45        }, 500));
46
47        observer.observe(document.body, {
48            childList: true,
49            subtree: true
50        });
51
52        // Add styles for the tracker UI
53        addStyles();
54    }
55
56    // Get current attachment count from a thread page
57    async function getCurrentAttachmentCount() {
58        // If we're on the attachments page, count from pagination
59        if (window.location.href.includes('/attachments')) {
60            return getAttachmentCountFromCurrentPage();
61        }
62        
63        // Otherwise try to find attachment count in the regular thread page
64        const attachmentLinks = document.querySelectorAll('a[href*="/attachments/"]');
65        const uniqueAttachments = new Set();
66        
67        attachmentLinks.forEach(link => {
68            const match = link.href.match(/\/attachments\/[^/]+\.(\d+)\//);
69            if (match) {
70                uniqueAttachments.add(match[1]);
71            }
72        });
73        
74        console.log('Found attachments on page:', uniqueAttachments.size);
75        return uniqueAttachments.size;
76    }
77
78    // Get attachment count from current attachments page
79    function getAttachmentCountFromCurrentPage() {
80        // Count attachments on current page
81        const attachmentsOnPage = document.querySelectorAll('.attachmentGallery-item').length;
82        
83        // Get total pages from pagination
84        const lastPageLink = document.querySelector('.pageNav-main li:last-child a');
85        if (lastPageLink) {
86            const lastPageMatch = lastPageLink.href.match(/page=(\d+)/);
87            if (lastPageMatch) {
88                const totalPages = parseInt(lastPageMatch[1]);
89                // Assume 48 attachments per page (standard for LPSG)
90                // Last page might have fewer, so we calculate: (totalPages - 1) * 48 + attachmentsOnLastPage
91                // But since we don't know last page count, we estimate
92                const estimatedTotal = totalPages * 48;
93                console.log(`Estimated ${estimatedTotal} total attachments from ${totalPages} pages`);
94                return estimatedTotal;
95            }
96        }
97        
98        // If no pagination, just return count on current page
99        return attachmentsOnPage;
100    }
101
102    // Update attachment count for current thread
103    async function updateCurrentThreadAttachments() {
104        const threadId = extractThreadId(window.location.href);
105        if (!threadId) return;
106
107        const trackedThreads = await GM.getValue('lpsg_tracked_threads', {});
108        
109        if (trackedThreads[threadId]) {
110            const currentCount = await getCurrentAttachmentCount();
111            trackedThreads[threadId].lastViewedAttachmentCount = currentCount;
112            trackedThreads[threadId].lastViewed = Date.now();
113            await GM.setValue('lpsg_tracked_threads', trackedThreads);
114            console.log(`Updated thread ${threadId} attachment count to ${currentCount}`);
115        }
116    }
117
118    // Fetch attachment count from a thread URL
119    async function fetchAttachmentCount(url) {
120        try {
121            // Convert thread URL to attachments page URL
122            const threadId = extractThreadId(url);
123            if (!threadId) return 0;
124            
125            // Build attachments URL
126            const baseUrl = url.split('?')[0].replace(/\/$/, '');
127            const attachmentsUrl = `${baseUrl}/attachments?filter=all&page=1`;
128            
129            console.log('Fetching attachment count from:', attachmentsUrl);
130            
131            const response = await GM.xmlhttpRequest({
132                method: 'GET',
133                url: attachmentsUrl,
134                timeout: 10000
135            });
136
137            const parser = new DOMParser();
138            const doc = parser.parseFromString(response.responseText, 'text/html');
139            
140            // Count attachments on first page
141            const attachmentsOnPage = doc.querySelectorAll('.attachmentGallery-item').length;
142            
143            // Get total pages from pagination
144            const lastPageLink = doc.querySelector('.pageNav-main li:last-child a');
145            if (lastPageLink) {
146                const lastPageMatch = lastPageLink.href.match(/page=(\d+)/);
147                if (lastPageMatch) {
148                    const totalPages = parseInt(lastPageMatch[1]);
149                    
150                    // Fetch the last page to get exact count
151                    const lastPageUrl = `${baseUrl}/attachments?filter=all&page=${totalPages}`;
152                    console.log('Fetching last page:', lastPageUrl);
153                    
154                    const lastPageResponse = await GM.xmlhttpRequest({
155                        method: 'GET',
156                        url: lastPageUrl,
157                        timeout: 10000
158                    });
159                    
160                    const lastPageDoc = parser.parseFromString(lastPageResponse.responseText, 'text/html');
161                    const attachmentsOnLastPage = lastPageDoc.querySelectorAll('.attachmentGallery-item').length;
162                    
163                    // Calculate exact total: (totalPages - 1) * 48 + attachmentsOnLastPage
164                    const exactTotal = ((totalPages - 1) * 48) + attachmentsOnLastPage;
165                    console.log(`Thread has exactly ${exactTotal} attachments (${totalPages} pages, ${attachmentsOnLastPage} on last page)`);
166                    return exactTotal;
167                }
168            }
169            
170            // If no pagination, return count on first page
171            console.log(`Thread has ${attachmentsOnPage} attachments (single page)`);
172            return attachmentsOnPage;
173        } catch (error) {
174            console.error('Error fetching attachment count:', error);
175            return 0;
176        }
177    }
178
179    // Add tracking buttons to thread elements
180    function addTrackingButtons() {
181        // Find all thread/forum nodes
182        const nodes = document.querySelectorAll('.node.node--forum, .structItem.structItem--thread');
183        
184        nodes.forEach(async (node) => {
185            // Skip if already processed
186            if (node.querySelector('.lpsg-tracker-btn')) {
187                return;
188            }
189
190            // Get thread/forum info
191            const linkElement = node.querySelector('a[href*="/forums/"], a[href*="/threads/"]');
192            if (!linkElement) return;
193
194            const url = linkElement.href;
195            const threadId = extractThreadId(url);
196            if (!threadId) return;
197
198            const title = linkElement.textContent.trim();
199
200            // Create tracking button
201            const trackBtn = document.createElement('button');
202            trackBtn.className = 'lpsg-tracker-btn';
203            trackBtn.innerHTML = '⭐';
204            trackBtn.title = 'Track this thread';
205            
206            // Check if already tracked
207            const trackedThreads = await GM.getValue('lpsg_tracked_threads', {});
208            if (trackedThreads[threadId]) {
209                trackBtn.classList.add('tracked');
210                trackBtn.innerHTML = '★';
211                trackBtn.title = 'Untrack this thread';
212            }
213
214            // Add click handler
215            trackBtn.addEventListener('click', async (e) => {
216                e.preventDefault();
217                e.stopPropagation();
218                await toggleTracking(threadId, title, url, trackBtn);
219            });
220
221            // Insert button into the node
222            const titleElement = node.querySelector('.node-title, .structItem-title');
223            if (titleElement) {
224                titleElement.style.position = 'relative';
225                titleElement.appendChild(trackBtn);
226            }
227        });
228    }
229
230    // Extract thread ID from URL
231    function extractThreadId(url) {
232        const match = url.match(/\/(forums|threads)\/[^/]+\.(\d+)\//);
233        return match ? match[2] : null;
234    }
235
236    // Toggle tracking for a thread
237    async function toggleTracking(threadId, title, url, button) {
238        const trackedThreads = await GM.getValue('lpsg_tracked_threads', {});
239        
240        if (trackedThreads[threadId]) {
241            // Untrack
242            delete trackedThreads[threadId];
243            button.classList.remove('tracked');
244            button.innerHTML = '⭐';
245            button.title = 'Track this thread';
246            console.log('Untracked thread:', title);
247        } else {
248            // Track - fetch initial attachment count
249            button.innerHTML = '⏳';
250            button.disabled = true;
251            
252            const attachmentCount = await fetchAttachmentCount(url);
253            
254            trackedThreads[threadId] = {
255                title: title,
256                url: url,
257                trackedAt: Date.now(),
258                lastViewed: Date.now(),
259                lastViewedAttachmentCount: attachmentCount,
260                currentAttachmentCount: attachmentCount
261            };
262            button.classList.add('tracked');
263            button.innerHTML = '★';
264            button.title = 'Untrack this thread';
265            button.disabled = false;
266            console.log('Tracked thread:', title, 'with', attachmentCount, 'attachments');
267        }
268        
269        await GM.setValue('lpsg_tracked_threads', trackedThreads);
270        
271        // Update tracker panel if it exists
272        updateTrackerPanel();
273    }
274
275    // Check for new attachments in tracked threads
276    async function checkForNewAttachments() {
277        const trackedThreads = await GM.getValue('lpsg_tracked_threads', {});
278        let updated = false;
279
280        for (const [threadId, data] of Object.entries(trackedThreads)) {
281            const currentCount = await fetchAttachmentCount(data.url);
282            if (currentCount !== data.currentAttachmentCount) {
283                trackedThreads[threadId].currentAttachmentCount = currentCount;
284                updated = true;
285                console.log(`Thread ${data.title} now has ${currentCount} attachments (was ${data.currentAttachmentCount})`);
286            }
287        }
288
289        if (updated) {
290            await GM.setValue('lpsg_tracked_threads', trackedThreads);
291            updateTrackerPanel();
292        }
293    }
294
295    // Add tracker panel to show tracked threads
296    function createTrackerPanel() {
297        const panel = document.createElement('div');
298        panel.id = 'lpsg-tracker-panel';
299        panel.className = 'lpsg-tracker-panel';
300        panel.innerHTML = `
301            <div class="lpsg-tracker-header">
302                <h3>📌 Tracked Threads</h3>
303                <div class="lpsg-tracker-header-actions">
304                    <button class="lpsg-tracker-refresh" title="Check for new attachments">🔄</button>
305                    <button class="lpsg-tracker-close">×</button>
306                </div>
307            </div>
308            <div class="lpsg-tracker-content">
309                <p class="lpsg-tracker-loading">Loading tracked threads...</p>
310            </div>
311        `;
312        
313        document.body.appendChild(panel);
314        
315        // Close button handler
316        panel.querySelector('.lpsg-tracker-close').addEventListener('click', () => {
317            panel.classList.remove('visible');
318        });
319
320        // Refresh button handler
321        panel.querySelector('.lpsg-tracker-refresh').addEventListener('click', async () => {
322            const refreshBtn = panel.querySelector('.lpsg-tracker-refresh');
323            refreshBtn.disabled = true;
324            refreshBtn.innerHTML = '⏳';
325            await checkForNewAttachments();
326            refreshBtn.disabled = false;
327            refreshBtn.innerHTML = '🔄';
328        });
329        
330        updateTrackerPanel();
331        return panel;
332    }
333
334    // Update tracker panel content
335    async function updateTrackerPanel() {
336        let panel = document.getElementById('lpsg-tracker-panel');
337        if (!panel) return;
338        
339        const content = panel.querySelector('.lpsg-tracker-content');
340        const trackedThreads = await GM.getValue('lpsg_tracked_threads', {});
341        
342        if (Object.keys(trackedThreads).length === 0) {
343            content.innerHTML = '<p class="lpsg-tracker-empty">No tracked threads yet. Click ⭐ on any thread to track it.</p>';
344            return;
345        }
346        
347        const threadsList = Object.entries(trackedThreads)
348            .sort((a, b) => b[1].trackedAt - a[1].trackedAt)
349            .map(([id, data]) => {
350                const newAttachments = (data.currentAttachmentCount || 0) - (data.lastViewedAttachmentCount || 0);
351                const hasNew = newAttachments > 0;
352                
353                return `
354                    <div class="lpsg-tracker-item ${hasNew ? 'has-new' : ''}">
355                        <div class="lpsg-tracker-item-content">
356                            <a href="${data.url}" target="_blank" class="lpsg-tracker-item-title">${data.title}</a>
357                            <div class="lpsg-tracker-item-stats">
358                                <span class="lpsg-tracker-attachments">
359                                    📎 ${data.currentAttachmentCount || 0} attachments
360                                    ${hasNew ? `<span class="lpsg-tracker-new-badge">+${newAttachments} new</span>` : ''}
361                                </span>
362                            </div>
363                        </div>
364                        <button class="lpsg-tracker-remove" data-id="${id}">Remove</button>
365                    </div>
366                `;
367            }).join('');
368        
369        content.innerHTML = threadsList;
370        
371        // Add remove handlers
372        content.querySelectorAll('.lpsg-tracker-remove').forEach(btn => {
373            btn.addEventListener('click', async (e) => {
374                const threadId = e.target.dataset.id;
375                const threads = await GM.getValue('lpsg_tracked_threads', {});
376                delete threads[threadId];
377                await GM.setValue('lpsg_tracked_threads', threads);
378                updateTrackerPanel();
379                
380                // Update button in the page
381                const pageBtn = document.querySelector(`.lpsg-tracker-btn[data-id="${threadId}"]`);
382                if (pageBtn) {
383                    pageBtn.classList.remove('tracked');
384                    pageBtn.innerHTML = '⭐';
385                }
386            });
387        });
388    }
389
390    // Add toggle button to show/hide tracker panel
391    function addToggleButton() {
392        const toggleBtn = document.createElement('button');
393        toggleBtn.id = 'lpsg-tracker-toggle';
394        toggleBtn.className = 'lpsg-tracker-toggle';
395        toggleBtn.innerHTML = '📌';
396        toggleBtn.title = 'Show tracked threads';
397        
398        toggleBtn.addEventListener('click', () => {
399            let panel = document.getElementById('lpsg-tracker-panel');
400            if (!panel) {
401                panel = createTrackerPanel();
402            }
403            panel.classList.toggle('visible');
404        });
405        
406        document.body.appendChild(toggleBtn);
407    }
408
409    // Add styles
410    function addStyles() {
411        TM_addStyle(`
412            .lpsg-tracker-btn {
413                position: absolute;
414                right: 0;
415                top: 50%;
416                transform: translateY(-50%);
417                background: transparent;
418                border: none;
419                font-size: 18px;
420                cursor: pointer;
421                padding: 4px 8px;
422                opacity: 0.5;
423                transition: opacity 0.2s, transform 0.2s;
424                z-index: 10;
425            }
426            
427            .lpsg-tracker-btn:hover {
428                opacity: 1;
429                transform: translateY(-50%) scale(1.2);
430            }
431            
432            .lpsg-tracker-btn.tracked {
433                opacity: 1;
434                color: #ffd700;
435            }
436
437            .lpsg-tracker-btn:disabled {
438                opacity: 0.3;
439                cursor: not-allowed;
440            }
441            
442            .lpsg-tracker-toggle {
443                position: fixed;
444                bottom: 20px;
445                right: 20px;
446                width: 50px;
447                height: 50px;
448                border-radius: 50%;
449                background: #2196F3;
450                color: white;
451                border: none;
452                font-size: 24px;
453                cursor: pointer;
454                box-shadow: 0 2px 10px rgba(0,0,0,0.3);
455                z-index: 9999;
456                transition: transform 0.2s, background 0.2s;
457            }
458            
459            .lpsg-tracker-toggle:hover {
460                transform: scale(1.1);
461                background: #1976D2;
462            }
463            
464            .lpsg-tracker-panel {
465                position: fixed;
466                top: 50%;
467                right: -420px;
468                transform: translateY(-50%);
469                width: 400px;
470                max-height: 80vh;
471                background: white;
472                border-radius: 8px;
473                box-shadow: 0 4px 20px rgba(0,0,0,0.3);
474                z-index: 10000;
475                transition: right 0.3s ease;
476                display: flex;
477                flex-direction: column;
478            }
479            
480            .lpsg-tracker-panel.visible {
481                right: 20px;
482            }
483            
484            .lpsg-tracker-header {
485                padding: 15px 20px;
486                background: #2196F3;
487                color: white;
488                border-radius: 8px 8px 0 0;
489                display: flex;
490                justify-content: space-between;
491                align-items: center;
492            }
493            
494            .lpsg-tracker-header h3 {
495                margin: 0;
496                font-size: 18px;
497            }
498
499            .lpsg-tracker-header-actions {
500                display: flex;
501                gap: 10px;
502                align-items: center;
503            }
504
505            .lpsg-tracker-refresh {
506                background: rgba(255, 255, 255, 0.2);
507                border: none;
508                color: white;
509                font-size: 18px;
510                cursor: pointer;
511                padding: 4px 8px;
512                border-radius: 4px;
513                transition: background 0.2s;
514            }
515
516            .lpsg-tracker-refresh:hover {
517                background: rgba(255, 255, 255, 0.3);
518            }
519
520            .lpsg-tracker-refresh:disabled {
521                opacity: 0.5;
522                cursor: not-allowed;
523            }
524            
525            .lpsg-tracker-close {
526                background: transparent;
527                border: none;
528                color: white;
529                font-size: 28px;
530                cursor: pointer;
531                padding: 0;
532                width: 30px;
533                height: 30px;
534                line-height: 1;
535            }
536            
537            .lpsg-tracker-content {
538                padding: 15px;
539                overflow-y: auto;
540                flex: 1;
541            }
542            
543            .lpsg-tracker-item {
544                padding: 12px;
545                margin-bottom: 10px;
546                background: #f5f5f5;
547                border-radius: 4px;
548                display: flex;
549                justify-content: space-between;
550                align-items: center;
551                gap: 10px;
552                border-left: 3px solid transparent;
553                transition: all 0.2s;
554            }
555
556            .lpsg-tracker-item.has-new {
557                background: #e3f2fd;
558                border-left-color: #2196F3;
559            }
560
561            .lpsg-tracker-item-content {
562                flex: 1;
563                display: flex;
564                flex-direction: column;
565                gap: 6px;
566            }
567            
568            .lpsg-tracker-item-title {
569                color: #2196F3;
570                text-decoration: none;
571                font-size: 14px;
572                font-weight: 500;
573            }
574            
575            .lpsg-tracker-item-title:hover {
576                text-decoration: underline;
577            }
578
579            .lpsg-tracker-item-stats {
580                font-size: 12px;
581                color: #666;
582                display: flex;
583                align-items: center;
584                gap: 8px;
585            }
586
587            .lpsg-tracker-attachments {
588                display: flex;
589                align-items: center;
590                gap: 6px;
591            }
592
593            .lpsg-tracker-new-badge {
594                background: #4CAF50;
595                color: white;
596                padding: 2px 6px;
597                border-radius: 10px;
598                font-size: 11px;
599                font-weight: bold;
600            }
601            
602            .lpsg-tracker-remove {
603                background: #f44336;
604                color: white;
605                border: none;
606                padding: 4px 10px;
607                border-radius: 4px;
608                cursor: pointer;
609                font-size: 12px;
610                white-space: nowrap;
611                flex-shrink: 0;
612            }
613            
614            .lpsg-tracker-remove:hover {
615                background: #d32f2f;
616            }
617            
618            .lpsg-tracker-empty,
619            .lpsg-tracker-loading {
620                text-align: center;
621                color: #666;
622                padding: 20px;
623            }
624        `);
625    }
626
627    // Wait for page to load and initialize
628    if (document.readyState === 'loading') {
629        document.addEventListener('DOMContentLoaded', () => {
630            init();
631            addToggleButton();
632        });
633    } else {
634        init();
635        addToggleButton();
636    }
637})();
LPSG Forums Post Tracker | Robomonkey