diff --git a/backend/routes/sessions.js b/backend/routes/sessions.js index d803270..dc6e079 100644 --- a/backend/routes/sessions.js +++ b/backend/routes/sessions.js @@ -440,5 +440,138 @@ router.delete('/:sessionId/games/:gameId', authenticateToken, (req, res) => { } }); +// Export session data (plaintext and JSON) +router.get('/:id/export', authenticateToken, (req, res) => { + try { + const { format } = req.query; // 'json' or 'txt' + const sessionId = req.params.id; + + // Get session info + const session = db.prepare(` + SELECT + s.*, + COUNT(sg.id) as games_played + FROM sessions s + LEFT JOIN session_games sg ON s.id = sg.session_id + WHERE s.id = ? + GROUP BY s.id + `).get(sessionId); + + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + + // Get games for this session + const games = db.prepare(` + SELECT + sg.*, + g.title, + g.pack_name, + g.min_players, + g.max_players, + g.game_type + FROM session_games sg + JOIN games g ON sg.game_id = g.id + WHERE sg.session_id = ? + ORDER BY sg.played_at ASC + `).all(sessionId); + + // Get chat logs if any + const chatLogs = db.prepare(` + SELECT * FROM chat_logs + WHERE session_id = ? + ORDER BY timestamp ASC + `).all(sessionId); + + if (format === 'json') { + // JSON Export + const exportData = { + session: { + id: session.id, + created_at: session.created_at, + closed_at: session.closed_at, + is_active: session.is_active === 1, + notes: session.notes, + games_played: session.games_played + }, + games: games.map(game => ({ + title: game.title, + pack: game.pack_name, + players: `${game.min_players}-${game.max_players}`, + type: game.game_type, + played_at: game.played_at, + manually_added: game.manually_added === 1, + status: game.status + })), + chat_logs: chatLogs.map(log => ({ + username: log.chatter_name, + message: log.message, + timestamp: log.timestamp, + vote: log.parsed_vote + })) + }; + + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', `attachment; filename="session-${sessionId}.json"`); + res.send(JSON.stringify(exportData, null, 2)); + } else { + // Plain Text Export + let text = `JACKBOX GAME PICKER - SESSION EXPORT\n`; + text += `${'='.repeat(50)}\n\n`; + text += `Session ID: ${session.id}\n`; + text += `Created: ${session.created_at}\n`; + if (session.closed_at) { + text += `Closed: ${session.closed_at}\n`; + } + text += `Status: ${session.is_active ? 'Active' : 'Ended'}\n`; + if (session.notes) { + text += `Notes: ${session.notes}\n`; + } + text += `\nGames Played: ${session.games_played}\n`; + text += `\n${'='.repeat(50)}\n\n`; + + if (games.length > 0) { + text += `GAMES:\n`; + text += `${'-'.repeat(50)}\n`; + games.forEach((game, index) => { + text += `\n${index + 1}. ${game.title}\n`; + text += ` Pack: ${game.pack_name}\n`; + text += ` Players: ${game.min_players}-${game.max_players}\n`; + if (game.game_type) { + text += ` Type: ${game.game_type}\n`; + } + text += ` Played: ${game.played_at}\n`; + text += ` Status: ${game.status}\n`; + if (game.manually_added === 1) { + text += ` (Manually Added)\n`; + } + }); + text += `\n${'-'.repeat(50)}\n`; + } + + if (chatLogs.length > 0) { + text += `\nCHAT LOGS:\n`; + text += `${'-'.repeat(50)}\n`; + chatLogs.forEach(log => { + text += `\n[${log.timestamp}] ${log.chatter_name}:\n`; + text += ` ${log.message}\n`; + if (log.parsed_vote) { + text += ` Vote: ${log.parsed_vote}\n`; + } + }); + text += `\n${'-'.repeat(50)}\n`; + } + + text += `\nEnd of Session Export\n`; + + res.setHeader('Content-Type', 'text/plain'); + res.setHeader('Content-Disposition', `attachment; filename="session-${sessionId}.txt"`); + res.send(text); + } + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + module.exports = router; diff --git a/chrome-extension/DEBUG.md b/chrome-extension/DEBUG.md new file mode 100644 index 0000000..c2322f9 --- /dev/null +++ b/chrome-extension/DEBUG.md @@ -0,0 +1,179 @@ +# Chrome Extension Debugging Guide + +## Version 3.0.2 - Apollo Client Detection Issues + +If the extension can't find Apollo Client, follow these steps to diagnose the issue: + +### Step 1: Reload Extension and Page + +1. Go to `chrome://extensions/` +2. Find "Jackbox Chat Tracker for Kosmi" +3. Click the reload icon (circular arrow) +4. Navigate to Kosmi room: `https://app.kosmi.io/room/@yourroomname` +5. Refresh the page (Ctrl+R or Cmd+R) + +### Step 2: Run Debug Function + +1. Open Chrome DevTools (F12) +2. Go to Console tab +3. Wait 5-10 seconds after page load (let Kosmi fully initialize) +4. Type: `window.debugJackboxTracker()` +5. Press Enter + +### Step 3: Analyze Output + +The debug function will show: + +#### If Apollo Client is Found ✓ +``` +=== Jackbox Chat Tracker Debug === +✓ Apollo Client found! +Apollo Client: ApolloClient {...} +Apollo Client keys: ['cache', 'link', 'queryManager', ...] +Apollo Link: {...} +Apollo Link keys: ['client', ...] +GraphQL client: {...} +GraphQL client keys: ['on', 'subscribe', 'iterate', 'dispose', 'terminate'] +=== End Debug === +``` + +**Action:** Try clicking "Start Tracking" in the extension popup. It should work now! + +#### If Apollo Client is NOT Found ✗ +``` +=== Jackbox Chat Tracker Debug === +✗ Apollo Client not found +Checking for window.__APOLLO_CLIENT__: undefined +Window keys that might be relevant: ['__someOtherKey__', ...] +=== End Debug === +``` + +**Action:** Copy the "Window keys that might be relevant" list and report it to the developer. + +### Step 4: Check Timing + +If Apollo Client is not found immediately, wait longer and try again: + +```javascript +// Run debug after 10 seconds +setTimeout(() => window.debugJackboxTracker(), 10000); +``` + +### Step 5: Manual Apollo Client Search + +If still not working, manually check for Apollo Client: + +```javascript +// Check if it exists +console.log('window.__APOLLO_CLIENT__:', window.__APOLLO_CLIENT__); + +// Search all window properties +Object.keys(window).forEach(key => { + if (key.includes('apollo') || key.includes('Apollo')) { + console.log('Found Apollo-related key:', key, window[key]); + } +}); + +// Check if it's in a different location +console.log('document.__APOLLO_CLIENT__:', document.__APOLLO_CLIENT__); +``` + +### Common Issues + +#### Issue: "Apollo Client not found after 15 attempts" + +**Causes:** +1. Kosmi changed their client variable name +2. Apollo Client loads after 15 seconds +3. You're not on a Kosmi room page + +**Solutions:** +1. Wait longer (30+ seconds) and run `window.debugJackboxTracker()` +2. Make sure you're on `app.kosmi.io/room/...` not just `app.kosmi.io` +3. Try a different room +4. Check if Kosmi updated their code + +#### Issue: "GraphQL client not found in Apollo Client" + +**Cause:** Apollo Client structure changed + +**Solution:** +1. Run debug function to see Apollo Client structure +2. Look for the WebSocket client in a different location +3. Report the structure to the developer + +### Reporting Issues + +When reporting Apollo Client not found, include: + +1. **Console Output:** + ``` + // Copy ALL of this: + window.debugJackboxTracker() + ``` + +2. **Window Keys:** + ```javascript + // Copy this: + Object.keys(window).filter(k => k.startsWith('__') || k.includes('apollo')) + ``` + +3. **Kosmi URL:** + - Full URL of the room (redact room name if private) + +4. **Browser Version:** + - Chrome version (chrome://version/) + +5. **Extension Version:** + - Check `chrome://extensions/` → "Jackbox Chat Tracker" → version number + +--- + +## Advanced Debugging + +### Hook Detection + +Check if the WebSocket hook is working: + +```javascript +// This should exist after successfully starting tracking +console.log('GraphQL Client:', window.__APOLLO_CLIENT__?.link?.client); + +// Try manually hooking +const client = window.__APOLLO_CLIENT__.link.client; +client.on('message', (data) => { + console.log('MANUAL HOOK - Message:', data); +}); +``` + +### Message Interception + +Test if messages are flowing: + +```javascript +// Send a test message in Kosmi chat: "test thisgame++" +// Check console for: +// [Jackbox Chat Tracker] New message: {...} +// [Jackbox Chat Tracker] ✓ Vote detected! +``` + +--- + +## Version History + +### v3.0.2 +- Added multiple Apollo Client detection strategies +- Added `window.debugJackboxTracker()` debug function +- Extended search to all window/document properties +- Increased retry limit to 15 attempts (15-30 seconds) +- Added detailed error logging + +### v3.0.1 +- Fixed infinite loop on page load +- Disabled auto-start tracking +- Added retry limit + +### v3.0.0 +- Initial WebSocket API implementation +- Replaced DOM parsing with GraphQL subscriptions + diff --git a/chrome-extension/README.md b/chrome-extension/README.md new file mode 100644 index 0000000..6d1c6ba --- /dev/null +++ b/chrome-extension/README.md @@ -0,0 +1,213 @@ +# Jackbox Chat Tracker Chrome Extension + +A Chrome extension for tracking Jackbox game votes (`thisgame++` and `thisgame--`) from Kosmi chat. + +## Features + +- ✅ Tracks `thisgame++` and `thisgame--` votes in Kosmi chat +- ✅ Records username, full message, and UTC timestamp +- ✅ Case-insensitive matching (works with `THISGAME++`, `ThisGame--`, etc.) +- ✅ Matches votes anywhere in the message +- ✅ Exports data as JSON in the format expected by the Jackbox Game Picker app +- ✅ Real-time vote tracking with live updates +- ✅ Simple, intuitive interface + +## Installation + +1. Open Chrome and navigate to `chrome://extensions/` +2. Enable "Developer mode" in the top right +3. Click "Load unpacked" +4. Select the `chrome-extension` folder +5. The extension is now installed! + +## Usage + +### Starting a Tracking Session + +1. Navigate to a Kosmi room: `https://app.kosmi.io/room/...` +2. Click the extension icon in your browser toolbar +3. Click "Start Tracking" +4. The extension will now monitor chat for votes + +### Viewing Votes + +- The popup shows all recorded votes in real-time +- Each vote displays: + - Username + - Full message text + - Timestamp (in your local timezone) + - Vote type (positive/negative indicated by color) + +### Exporting Data + +1. Click "Export JSON" to download votes +2. The file format matches the Jackbox Game Picker import format: + ```json + [ + { + "username": "Alice", + "message": "thisgame++", + "timestamp": "2025-10-30T20:15:00Z" + } + ] + ``` +3. Import this JSON file into the Jackbox Game Picker app + +### Resetting Votes + +- Click "Reset Votes" to clear all recorded votes +- Confirmation dialog prevents accidental resets + +## How It Works + +The extension hooks into Kosmi's GraphQL WebSocket API (`wss://engine.kosmi.io/gql-ws`) to monitor real-time chat messages. This is much more reliable than DOM parsing! + +When a message is received, the extension: +1. Checks if it contains `thisgame++` or `thisgame--` (case-insensitive) +2. Extracts the **username** from the GraphQL response +3. Extracts the **full message text** +4. Records the **UTC timestamp** + +### Why WebSocket? + +Previous versions used DOM parsing (MutationObserver), which was fragile and prone to breaking when Kosmi updated their UI. Version 3.0.0 uses Kosmi's own GraphQL API, which is: +- ✅ More reliable (API is stable) +- ✅ Faster (no DOM traversal needed) +- ✅ More accurate (username is always correct) +- ✅ Less CPU intensive (no constant DOM monitoring) + +## Vote Format + +**Input** (Kosmi chat): +``` +Alice: This is great! thisgame++ +Bob: thisgame-- not my favorite +Charlie: THISGAME++ love it! +``` + +**Output** (JSON export): +```json +[ + { + "username": "Alice", + "message": "This is great! thisgame++", + "timestamp": "2025-10-30T20:15:00.123Z" + }, + { + "username": "Bob", + "message": "thisgame-- not my favorite", + "timestamp": "2025-10-30T20:15:15.456Z" + }, + { + "username": "Charlie", + "message": "THISGAME++ love it!", + "timestamp": "2025-10-30T20:15:30.789Z" + } +] +``` + +## Technical Details + +### Files + +- `manifest.json`: Extension configuration +- `content.js`: Monitors Kosmi chat and records votes +- `popup.html`: Extension popup interface +- `popup.js`: Popup logic and controls +- `popup.css`: Popup styling +- `icons/`: Extension icons + +### Permissions + +- `storage`: Save votes locally +- `activeTab`: Access current Kosmi tab +- `downloads`: Export JSON files +- `https://app.kosmi.io/*`: Access Kosmi chat + +### Data Storage + +- Votes are stored locally using Chrome's `storage.local` API +- Data persists across browser sessions +- No data is sent to external servers + +## Changelog + +### Version 3.0.1 (Current) + +- 🐛 Fixed infinite retry loop when Apollo Client not found +- 🐛 Disabled auto-start on page load (user must manually start tracking) +- ✅ Added retry limit (10 attempts max, then gives up gracefully) +- ✅ Only sets `isTracking = true` after successfully hooking into Apollo Client +- ✅ Better error messages with attempt counter + +### Version 3.0.0 + +- 🎉 **Complete rewrite using GraphQL WebSocket API** +- ✅ Hooks directly into Kosmi's Apollo Client +- ✅ 100% reliable message and username extraction +- ✅ No more DOM parsing or MutationObserver +- ✅ Lightweight and performant +- ✅ Future-proof (API-based, not DOM-based) +- ❌ Removed fragile DOM traversal code +- ❌ No more "Unknown" usernames + +### Version 2.0.2 + +- 🐛 Fixed sidebar responsiveness with polling updates +- 🐛 Fixed MutationObserver not detecting new messages +- 🐛 Suppressed "Could not establish connection" errors +- ✅ Added extensive debug logging for troubleshooting +- ✅ Observer now properly disconnects and reconnects on start + +### Version 2.0.1 + +- 🐛 Fixed duplicate vote counting issue +- 🐛 Improved username extraction with multiple fallback strategies +- ✅ Re-added sidebar panel support + +### Version 2.0.0 + +- ✅ Simplified to only track `thisgame++` and `thisgame--` +- ✅ Removed complex game alias system +- ✅ Captures full message context +- ✅ UTC timestamp recording +- ✅ New JSON export format +- ✅ Improved UI with vote preview +- ✅ Case-insensitive matching + +## Troubleshooting + +**Extension not detecting votes:** +1. Make sure you're on a Kosmi room page (`app.kosmi.io/room/...`) +2. Click "Stop Tracking" then "Start Tracking" to reset +3. Check browser console for errors (F12 → Console tab) + +**Votes not saving:** +1. Check Chrome's storage permissions +2. Try resetting votes and starting fresh +3. Ensure extension has permission to access Kosmi + +**Export not working:** +1. Check Chrome's download permissions +2. Ensure popup blockers aren't blocking downloads +3. Try clicking "Export JSON" again + +**Extension not loading:** +1. Make sure you've reloaded the extension after updating files +2. Go to `chrome://extensions/`, find "Jackbox Chat Tracker", and click the reload icon +3. Refresh the Kosmi room page after reloading the extension + +**Apollo Client not found:** +1. The extension needs a moment after page load to find Apollo Client +2. Wait 2-3 seconds after the page loads, then click "Start Tracking" +3. Check console for "Apollo Client not found!" error +4. If error persists, Kosmi may have changed their client framework + +## Support + +For issues or questions, please file an issue in the main repository. + +## License + +Same as main Jackbox Game Picker project. + diff --git a/chrome-extension/content.js b/chrome-extension/content.js new file mode 100644 index 0000000..3c93fe6 --- /dev/null +++ b/chrome-extension/content.js @@ -0,0 +1,153 @@ +// Jackbox Chat Tracker for Kosmi - Content Script v3.2.0 +// This version intercepts the WebSocket connection directly + +// Inject the hook script ASAP (before page JavaScript runs) +const script = document.createElement('script'); +script.src = chrome.runtime.getURL('inject.js'); +script.onload = function() { + this.remove(); +}; +(document.head || document.documentElement).appendChild(script); + +let isTracking = false; +let votes = []; +let socketFound = false; +let socketUrl = null; + +// Listen for messages from inject script (different JavaScript context!) +window.addEventListener('message', (event) => { + // Only accept messages from same origin + if (event.source !== window) return; + + if (event.data.type === 'JACKBOX_TRACKER_SOCKET_FOUND') { + console.log('[Jackbox Chat Tracker] WebSocket connected'); + socketFound = true; + socketUrl = event.data.url; + } else if (event.data.type === 'JACKBOX_TRACKER_WS_MESSAGE') { + // Message from WebSocket - only process if tracking + if (isTracking) { + handleGraphQLMessage(event.data.data); + } + } +}); + +// Initialize storage +chrome.storage.local.get(['isTracking', 'votes'], (result) => { + // Always default to NOT tracking on page load + isTracking = false; + votes = result.votes || []; + + // Clear tracking state in storage (user must manually start) + chrome.storage.local.set({ isTracking: false }); +}); + +// Listen for commands from popup +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.action === 'startTracking') { + startTracking(); + sendResponse({ success: true }); + } else if (request.action === 'stopTracking') { + stopTracking(); + sendResponse({ success: true }); + } else if (request.action === 'getVotes') { + sendResponse({ votes: votes }); + } else if (request.action === 'resetVotes') { + votes = []; + chrome.storage.local.set({ votes: [] }); + sendResponse({ success: true }); + } else if (request.action === 'getStatus') { + sendResponse({ isTracking: isTracking, voteCount: votes.length }); + } + + return true; // Keep channel open for async response +}); + +let startTrackingAttempts = 0; +const MAX_TRACKING_ATTEMPTS = 10; // Try for max 10 seconds + +function startTracking() { + try { + if (!socketFound) { + startTrackingAttempts++; + + if (startTrackingAttempts >= MAX_TRACKING_ATTEMPTS) { + console.error('[Jackbox Chat Tracker] WebSocket not found. Please refresh the page and try again.'); + startTrackingAttempts = 0; + notifyPopup(); + return; + } + + setTimeout(startTracking, 1000); + return; + } + + // Found the WebSocket! + startTrackingAttempts = 0; + isTracking = true; + chrome.storage.local.set({ isTracking: true }); + + console.log('[Jackbox Chat Tracker] Tracking started - ready to capture votes'); + notifyPopup(); + + } catch (error) { + console.error('[Jackbox Chat Tracker] Error starting tracking:', error); + startTrackingAttempts = 0; + notifyPopup(); + } +} + +function stopTracking() { + isTracking = false; + startTrackingAttempts = 0; + chrome.storage.local.set({ isTracking: false }); + + console.log('[Jackbox Chat Tracker] Tracking stopped'); + notifyPopup(); +} + +function handleGraphQLMessage(data) { + if (!isTracking) return; + + try { + // Check if this is a newMessage event + if (data.type === 'next' && + data.payload && + data.payload.data && + data.payload.data.newMessage) { + + const message = data.payload.data.newMessage; + const body = message.body; + const username = message.user.displayName || message.user.username || 'Unknown'; + const timestamp = new Date(message.time * 1000).toISOString(); // Convert UNIX timestamp to ISO + + // Check if message contains thisgame++ or thisgame-- + if (/thisgame\+\+/i.test(body) || /thisgame--/i.test(body)) { + console.log('[Jackbox Chat Tracker] Vote detected:', username); + + const vote = { + username: username, + message: body, + timestamp: timestamp + }; + + votes.push(vote); + chrome.storage.local.set({ votes: votes }); + + notifyPopup(); + } + } + } catch (error) { + console.error('[Jackbox Chat Tracker] Error processing message:', error); + } +} + +function notifyPopup() { + // Send update to popup if it's open + chrome.runtime.sendMessage({ + action: 'votesUpdated', + votes: votes, + isTracking: isTracking + }).catch(() => { + // Popup is closed, ignore error + }); +} diff --git a/chrome-extension/icons/icon.png b/chrome-extension/icons/icon.png new file mode 100644 index 0000000..77d9a84 Binary files /dev/null and b/chrome-extension/icons/icon.png differ diff --git a/chrome-extension/icons/icon128.png b/chrome-extension/icons/icon128.png new file mode 100644 index 0000000..a920ec9 Binary files /dev/null and b/chrome-extension/icons/icon128.png differ diff --git a/chrome-extension/icons/icon16.png b/chrome-extension/icons/icon16.png new file mode 100644 index 0000000..ca814eb Binary files /dev/null and b/chrome-extension/icons/icon16.png differ diff --git a/chrome-extension/icons/icon48.png b/chrome-extension/icons/icon48.png new file mode 100644 index 0000000..85c6a4a Binary files /dev/null and b/chrome-extension/icons/icon48.png differ diff --git a/chrome-extension/inject.js b/chrome-extension/inject.js new file mode 100644 index 0000000..adb2de0 --- /dev/null +++ b/chrome-extension/inject.js @@ -0,0 +1,92 @@ +// Jackbox Chat Tracker - Injected Script v3.2.0 +// This script intercepts Kosmi's WebSocket connection to capture chat messages +// NOTE: This runs in the PAGE CONTEXT, not extension context! + +// Store original WebSocket constructor +const OriginalWebSocket = window.WebSocket; + +// Hook WebSocket constructor to capture Kosmi's WebSocket +window.WebSocket = function(url, protocols) { + const socket = new OriginalWebSocket(url, protocols); + + // Check if this is Kosmi's GraphQL WebSocket + if (url.includes('engine.kosmi.io') || url.includes('gql-ws')) { + console.log('[Jackbox Chat Tracker] WebSocket hook active'); + + // Notify content script that we found the socket + window.postMessage({ + type: 'JACKBOX_TRACKER_SOCKET_FOUND', + url: url + }, '*'); + + // Method 1: Hook addEventListener (for event listeners added later) + const originalAddEventListener = socket.addEventListener.bind(socket); + socket.addEventListener = function(type, listener, options) { + if (type === 'message') { + const wrappedListener = function(event) { + // Forward to content script + try { + const data = JSON.parse(event.data); + window.postMessage({ + type: 'JACKBOX_TRACKER_WS_MESSAGE', + data: data + }, '*'); + } catch (e) { + // Not JSON, skip + } + + // Then forward to original listener + return listener.call(this, event); + }; + return originalAddEventListener(type, wrappedListener, options); + } + return originalAddEventListener(type, listener, options); + }; + + // Method 2: Intercept the onmessage property setter (for direct assignment) + let realOnMessage = null; + const descriptor = Object.getOwnPropertyDescriptor(WebSocket.prototype, 'onmessage'); + + Object.defineProperty(socket, 'onmessage', { + get: function() { + return realOnMessage; + }, + set: function(handler) { + // Create wrapped handler + realOnMessage = function(event) { + // Forward to content script + try { + const data = JSON.parse(event.data); + window.postMessage({ + type: 'JACKBOX_TRACKER_WS_MESSAGE', + data: data + }, '*'); + } catch (e) { + // Not JSON, skip + } + + // ALWAYS call original handler + if (handler) { + handler.call(socket, event); + } + }; + + // Actually set it on the underlying WebSocket using the original descriptor + if (descriptor && descriptor.set) { + descriptor.set.call(socket, realOnMessage); + } + }, + configurable: true + }); + } + + return socket; +}; + +// Preserve the original constructor properties +window.WebSocket.prototype = OriginalWebSocket.prototype; +window.WebSocket.CONNECTING = OriginalWebSocket.CONNECTING; +window.WebSocket.OPEN = OriginalWebSocket.OPEN; +window.WebSocket.CLOSING = OriginalWebSocket.CLOSING; +window.WebSocket.CLOSED = OriginalWebSocket.CLOSED; + diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json new file mode 100644 index 0000000..5bcf62a --- /dev/null +++ b/chrome-extension/manifest.json @@ -0,0 +1,45 @@ +{ + "manifest_version": 3, + "name": "Jackbox Chat Tracker for Kosmi", + "version": "3.2.0", + "description": "Track thisgame++ and thisgame-- votes from Kosmi chat for Jackbox games", + "permissions": [ + "storage", + "activeTab", + "downloads", + "sidePanel" + ], + "host_permissions": [ + "https://app.kosmi.io/*" + ], + "content_scripts": [ + { + "matches": ["https://app.kosmi.io/room/*"], + "js": ["content.js"], + "run_at": "document_start" + } + ], + "web_accessible_resources": [ + { + "resources": ["inject.js"], + "matches": ["https://app.kosmi.io/*"] + } + ], + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "side_panel": { + "default_path": "popup.html" + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } +} + diff --git a/chrome-extension/popup.css b/chrome-extension/popup.css new file mode 100644 index 0000000..1f49fb5 --- /dev/null +++ b/chrome-extension/popup.css @@ -0,0 +1,211 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + width: 450px; + min-height: 500px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + font-size: 14px; + color: #333; + background: #f5f5f5; +} + +.container { + display: flex; + flex-direction: column; + height: 100%; +} + +header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 20px; + text-align: center; +} + +header h1 { + font-size: 20px; + margin-bottom: 5px; +} + +header .subtitle { + font-size: 12px; + opacity: 0.9; +} + +main { + padding: 15px; + flex: 1; + overflow-y: auto; +} + +section { + background: white; + border-radius: 8px; + padding: 15px; + margin-bottom: 15px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +section h2 { + font-size: 16px; + margin-bottom: 12px; + color: #667eea; +} + +.tracking-status { + display: flex; + align-items: center; + margin-bottom: 12px; + padding: 10px; + background: #f9f9f9; + border-radius: 4px; +} + +.status-indicator { + width: 10px; + height: 10px; + border-radius: 50%; + background: #ccc; + margin-right: 10px; +} + +.status-indicator.active { + background: #4caf50; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +#statusText { + font-weight: 600; + color: #555; +} + +.button-group { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.btn { + flex: 1; + padding: 10px 15px; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.btn:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: #667eea; + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: #5568d3; +} + +.btn-secondary { + background: #95a5a6; + color: white; +} + +.btn-secondary:hover:not(:disabled) { + background: #7f8c8d; +} + +.btn-danger { + background: #e74c3c; + color: white; +} + +.btn-danger:hover:not(:disabled) { + background: #c0392b; +} + +.btn-sm { + padding: 6px 12px; + font-size: 12px; +} + +.votes-list { + max-height: 300px; + overflow-y: auto; + margin-bottom: 12px; +} + +.empty-state { + text-align: center; + color: #999; + padding: 20px; + font-style: italic; +} + +.vote-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.vote-item { + padding: 10px; + border-radius: 4px; + border-left: 3px solid #ccc; +} + +.vote-item.vote-positive { + background: rgba(76, 175, 80, 0.05); + border-left-color: #4caf50; +} + +.vote-item.vote-negative { + background: rgba(231, 76, 60, 0.05); + border-left-color: #e74c3c; +} + +.vote-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} + +.vote-header strong { + color: #333; + font-size: 13px; +} + +.vote-time { + font-size: 11px; + color: #999; +} + +.vote-message { + font-size: 12px; + color: #666; + font-style: italic; +} + diff --git a/chrome-extension/popup.html b/chrome-extension/popup.html new file mode 100644 index 0000000..a71b333 --- /dev/null +++ b/chrome-extension/popup.html @@ -0,0 +1,47 @@ + + + + + + Jackbox Chat Tracker + + + +
+
+

🎮 Jackbox Chat Tracker

+

Track thisgame++ and thisgame-- votes

+
+ +
+ +
+

Tracking

+
+ + Not Tracking +
+
+ + +
+
+ + +
+

Recorded Votes (0)

+
+

No votes recorded yet. Start tracking to begin!

+
+
+ + +
+
+
+
+ + + + + diff --git a/chrome-extension/popup.js b/chrome-extension/popup.js new file mode 100644 index 0000000..4de4f37 --- /dev/null +++ b/chrome-extension/popup.js @@ -0,0 +1,195 @@ +// Popup script for Jackbox Chat Tracker +console.log('Jackbox Chat Tracker: Popup loaded'); + +let isTracking = false; +let votes = []; + +// DOM elements +const startBtn = document.getElementById('startTracking'); +const stopBtn = document.getElementById('stopTracking'); +const statusIndicator = document.getElementById('statusIndicator'); +const statusText = document.getElementById('statusText'); +const votesDisplay = document.getElementById('votesDisplay'); +const voteCount = document.getElementById('voteCount'); +const resetVotesBtn = document.getElementById('resetVotes'); +const exportVotesBtn = document.getElementById('exportVotes'); + +// Initialize +loadState(); + +// Poll for updates every 2 seconds to keep sidebar in sync +setInterval(() => { + chrome.storage.local.get(['isTracking', 'votes'], (result) => { + const wasTracking = isTracking; + isTracking = result.isTracking || false; + votes = result.votes || []; + + if (wasTracking !== isTracking) { + updateTrackingUI(); + } + updateDisplay(); + }); +}, 2000); + +// Event listeners +startBtn.addEventListener('click', startTracking); +stopBtn.addEventListener('click', stopTracking); +resetVotesBtn.addEventListener('click', resetVotes); +exportVotesBtn.addEventListener('click', exportVotes); + +// Listen for updates from content script +chrome.runtime.onMessage.addListener((message) => { + if (message.action === 'votesUpdated') { + votes = message.votes; + updateDisplay(); + } +}); + +function loadState() { + chrome.storage.local.get(['isTracking', 'votes'], (result) => { + isTracking = result.isTracking || false; + votes = result.votes || []; + + updateDisplay(); + updateTrackingUI(); + + // Request current votes from content script + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + if (tabs[0]) { + chrome.tabs.sendMessage(tabs[0].id, { action: 'getVotes' }, (response) => { + if (response && response.votes) { + votes = response.votes; + updateDisplay(); + } + }); + } + }); + }); +} + +function startTracking() { + isTracking = true; + chrome.storage.local.set({ isTracking }); + + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + if (tabs[0]) { + chrome.tabs.sendMessage(tabs[0].id, { action: 'startTracking' }, (response) => { + if (response && response.success) { + console.log('Tracking started'); + updateTrackingUI(); + } + }); + } + }); +} + +function stopTracking() { + isTracking = false; + chrome.storage.local.set({ isTracking }); + + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + if (tabs[0]) { + chrome.tabs.sendMessage(tabs[0].id, { action: 'stopTracking' }, (response) => { + if (response && response.success) { + console.log('Tracking stopped'); + updateTrackingUI(); + } + }); + } + }); +} + +function resetVotes() { + if (!confirm('Reset all recorded votes? This cannot be undone.')) { + return; + } + + votes = []; + chrome.storage.local.set({ votes }); + + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + if (tabs[0]) { + chrome.tabs.sendMessage(tabs[0].id, { action: 'resetVotes' }, (response) => { + if (response && response.success) { + console.log('Votes reset'); + updateDisplay(); + } + }); + } + }); +} + +function exportVotes() { + // Export votes in the new format + const exportData = votes.map(vote => ({ + username: vote.username, + message: vote.message, + timestamp: vote.timestamp + })); + + const dataStr = JSON.stringify(exportData, null, 2); + const dataBlob = new Blob([dataStr], { type: 'application/json' }); + const url = URL.createObjectURL(dataBlob); + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `jackbox-votes-${timestamp}.json`; + + chrome.downloads.download({ + url: url, + filename: filename, + saveAs: true + }); +} + +function updateTrackingUI() { + if (isTracking) { + statusIndicator.classList.add('active'); + statusText.textContent = 'Tracking Active'; + startBtn.disabled = true; + stopBtn.disabled = false; + } else { + statusIndicator.classList.remove('active'); + statusText.textContent = 'Not Tracking'; + startBtn.disabled = false; + stopBtn.disabled = true; + } +} + +function updateDisplay() { + voteCount.textContent = votes.length; + + if (votes.length === 0) { + votesDisplay.innerHTML = '

No votes recorded yet. Start tracking to begin!

'; + return; + } + + // Display votes in reverse chronological order (most recent first) + const sortedVotes = [...votes].reverse(); + + let html = '
'; + sortedVotes.forEach((vote, index) => { + const localTime = new Date(vote.timestamp).toLocaleString(); + const isPositive = vote.message.includes('++'); + const voteClass = isPositive ? 'vote-positive' : 'vote-negative'; + + html += ` +
+
+ ${escapeHtml(vote.username)} + ${localTime} +
+
${escapeHtml(vote.message)}
+
+ `; + }); + html += '
'; + + votesDisplay.innerHTML = html; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + diff --git a/frontend/src/pages/History.jsx b/frontend/src/pages/History.jsx index d531b93..77b9720 100644 --- a/frontend/src/pages/History.jsx +++ b/frontend/src/pages/History.jsx @@ -56,6 +56,29 @@ function History() { } }; + const handleExport = async (sessionId, format) => { + try { + const response = await api.get(`/sessions/${sessionId}/export?format=${format}`, { + responseType: 'blob' + }); + + // Create download link + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement('a'); + link.href = url; + link.setAttribute('download', `session-${sessionId}.${format === 'json' ? 'json' : 'txt'}`); + document.body.appendChild(link); + link.click(); + link.parentNode.removeChild(link); + window.URL.revokeObjectURL(url); + + success(`Session exported as ${format.toUpperCase()}`); + } catch (err) { + console.error('Failed to export session', err); + error('Failed to export session'); + } + }; + const loadSessionGames = async (sessionId, silent = false) => { try { const response = await api.get(`/sessions/${sessionId}/games`); @@ -227,14 +250,32 @@ function History() { )} - {isAuthenticated && sessions.find(s => s.id === selectedSession)?.is_active === 1 && ( - - )} +
+ {isAuthenticated && sessions.find(s => s.id === selectedSession)?.is_active === 1 && ( + + )} + {isAuthenticated && ( + <> + + + + )} +
{showChatImport && ( diff --git a/todos.md b/todos.md index c3facd2..5eaa0e0 100644 --- a/todos.md +++ b/todos.md @@ -1,11 +1,11 @@ # TODO: ## Chrome Extension -- [ ] /.old-chrome-extension/ contains OLD code that needs adjusting for new game picker format. -- [ ] remove clunky gamealias system, we are only tracking "thisgame++" and "thisgame--" now. -- [ ] ensure the extension is watching for "thisgame++" or "thisgame--" anywhere in each chat line. -- [ ] if a chat line matches capture the whole message/line, the author, and the timestamp (UTC). -- [ ] ensure our JSON output matches the new format: +- [x] /.old-chrome-extension/ contains OLD code that needs adjusting for new game picker format. (COMPLETED: New simplified extension in /chrome-extension/) +- [x] remove clunky gamealias system, we are only tracking "thisgame++" and "thisgame--" now. +- [x] ensure the extension is watching for "thisgame++" or "thisgame--" anywhere in each chat line. +- [x] if a chat line matches capture the whole message/line, the author, and the timestamp (UTC). +- [x] ensure our JSON output matches the new format: ```json [ { @@ -31,4 +31,4 @@ - [x] Entire App: local timezone display still isn't working. I see UTC times. (FIXED: Created dateUtils.js to properly parse SQLite UTC timestamps) ## Other Features -- [ ] Session History: export sessions to plaintext and JSON. \ No newline at end of file +- [x] Session History: export sessions to plaintext and JSON. (COMPLETED: Export buttons in History page) \ No newline at end of file