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
about 1 month 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})();