// ==UserScript==
// @name Mavens Hand History Saver
// @namespace strobe878
// @version 5
// @description Saves poker hand histories and allows downloading them as ZIP files
// @author strobe878
// @match *://<insert-your-mavens-url-here>/*
// @grant none
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const dbVersion = 2;
// Track tables and their current hand histories
const tables = new Map(); // tableId -> TableHand
// Global variables
let username = null;
let menuItem = null;
let previousUsername = null;
const initializedDatabases = new Set();
// Constants
const DB_PREFIX = 'PokerHandsDB_';
const STORE_NAME = 'hands';
// Check if we're running in a mobile context
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
// Add error handling for script initialization
window.addEventListener('error', function(e) {
console.error('[PHHS] Global error:', e.message, 'at', e.filename, ':', e.lineno);
});
// Define the debounce helper before using it
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Main observer for detecting tables and username changes
const mainObserver = new MutationObserver(
debounce(async (mutations) => {
// Try both desktop and mobile selectors for lobby
let lobbyDiv = isMobile ?
document.getElementById('SiteMobile') :
document.querySelector('div#Lobby > div.header > div.title');
if (!lobbyDiv) {
return;
}
const lobbyTitle = lobbyDiv.textContent;
// Get username from lobby
let newUsername = null;
if (isMobile) {
// two cases: lobby view or table view
newUsername = (lobbyTitle.match(/.+ - Logged in as (.+)/) || [])[1]?.trim() ||
(lobbyTitle.match(/.+ - (.+)/) || [])[1]?.trim() ||
null;
} else {
newUsername = (lobbyTitle.match(/Lobby - (.+) logged in/) || [])[1]?.trim() ||
null;
}
// Update UI if username changed
if (newUsername !== username) {
updateUI(newUsername);
username = newUsername;
}
if (!username) {
for (const [tableId, table] of tables) {
await saveAndRemoveTable(tableId);
}
return;
}
// Initialize database if needed
try {
await initializeDB(username);
} catch (error) {
console.error('[PHHS] Failed to initialize database:', error);
return;
}
// Find active tables
const tableDivs = Array.from(document.querySelectorAll('div[class="dialog"]:has(> div[class="tablecontent"])'));
const activeTables = tableDivs
.map(tableDiv => {
let currentDiv = tableDiv.nextElementSibling;
while (currentDiv) {
if (currentDiv.classList.contains('dialog')) {
const infotabs = currentDiv.querySelector('div.infotabs');
if (infotabs) return infotabs;
}
currentDiv = currentDiv.nextElementSibling;
}
return null;
})
.filter(Boolean);
// Remove closed tables
for (const [tableId, table] of tables) {
if (!activeTables.includes(table.div)) {
await saveAndRemoveTable(tableId);
}
}
// Process active tables
activeTables.forEach(tableDiv => {
const infoDiv = tableDiv.querySelector('div.generalinfo > div.memo > pre');
if (!infoDiv?.textContent) return;
const tableMatch = infoDiv.textContent.match(/^Table name:\s+(.+)/) ||
infoDiv.textContent.match(/^Tournament name:\s+(.*)/);
const typeMatch = infoDiv.textContent.match(/Type:\s+(.+)/);
if (!tableMatch || !typeMatch) return;
const tableName = tableMatch[1].trim();
const tableType = typeMatch[1].trim();
const tableId = `${tableName} [${tableType}]`;
// Create new table tracking if it doesn't exist
if (!tables.has(tableId)) {
const tableHand = new TableHand(tableId);
tables.set(tableId, tableHand);
tableHand.attachTo(tableDiv);
}
});
}, 500)
);
// Helper function to save and remove a table
async function saveAndRemoveTable(tableId) {
const table = tables.get(tableId);
if (table) {
await table.save();
table.detach();
tables.delete(tableId);
}
}
// Function to start observing
const startObserving = () => {
// Try both desktop and mobile selectors
const clientDiv = document.getElementById('client_div') ||
document.getElementById('SiteMobile') ||
document.querySelector('.site-mobile');
if (clientDiv) {
mainObserver.observe(clientDiv, {
childList: true,
subtree: true
});
} else {
setTimeout(startObserving, 2000);
}
};
// Start immediately but also retry if needed
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startObserving);
} else {
startObserving();
}
// Get database name for current user
const getDBName = (username) => `${DB_PREFIX}${username}`;
// Structure for tracking a table's current hand
class TableHand {
constructor(tableId) {
this.tableId = tableId;
this.handNumber = null;
this.lines = [];
this.div = null;
this.observer = null;
}
async addLines(text) {
const handMatch = text.match(/Hand #(\d+-\d+)/);
if (!handMatch) {
return;
}
const handNumber = handMatch[1];
if (this.handNumber !== handNumber) {
await this.save();
this.handNumber = handNumber;
}
this.lines = text
.split('<br>')
.map(line => line.trim())
.filter(line => line);
}
async save() {
if (!username || !this.handNumber) {
return;
}
const filename = `${this.tableId}-${this.handNumber}.txt`;
try {
await withDB(username, async (db) => {
const transaction = db.transaction([STORE_NAME], 'readwrite');
const store = transaction.objectStore(STORE_NAME);
await Promise.all([
new Promise((resolve, reject) => {
const request = store.put({
tableId: this.tableId,
handNumber: this.handNumber,
content: this.lines.join('\n'),
contentType: 'text/plain'
});
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
}),
new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
})
]);
});
} catch (error) {
console.error(`[PHHS] Error saving hand ${filename}:`, error);
}
this.handNumber = null;
this.lines = [];
}
attachTo(tableDiv) {
this.div = tableDiv;
this.observer = createTableObserver(tableDiv, this.tableId, username);
}
detach() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
this.div = null;
}
}
// Create observer for a table's history
function createTableObserver(tableDiv, tableId, username) {
const historyDiv = tableDiv.querySelector('div.historyinfo > div.memo');
if (!historyDiv) {
console.error(`[PHHS] Could not find history div for table ${tableId}`);
return null;
}
const observer = new MutationObserver(async (mutations) => {
const historyText = historyDiv.innerHTML;
const tableHand = tables.get(tableId);
await tableHand.addLines(historyText);
});
observer.observe(historyDiv, {
childList: true,
characterData: true,
subtree: true
});
return observer;
}
// Helper for creating menu items
const createMenuItem = (id, text, handler) => {
const item = document.createElement('li');
item.id = id;
item.textContent = text;
$(item).on("touchstart mousedown", function(e) {
if (!menuItem) {
menuItem = this;
return false;
}
});
$(item).on("touchend mouseup", function(e) {
if (!menuItem) return;
const wasMenuItem = (this === menuItem);
$(this).parent().hide();
menuItem = null;
if (wasMenuItem) {
handler();
}
return false;
});
return item;
};
// Create UI elements
const createUI = () => {
const accountSpan = document.querySelector('span#AccountMenu');
if (!accountSpan) {
console.error('[PHHS] Account menu span not found');
return null;
}
const accountMenu = accountSpan.nextElementSibling;
if (!accountMenu || accountMenu.tagName !== 'UL') {
console.error('[PHHS] Account menu ul not found');
return null;
}
const computedStyle = window.getComputedStyle(accountMenu);
const defaultColor = computedStyle.color;
const defaultBgColor = computedStyle.backgroundColor;
// Create Download Hands menu item
const downloadItem = createMenuItem('AccountDownloadHands', 'Download hand histories...', handleDownload);
const clearItem = createMenuItem('AccountClearHands', 'Clear hand histories...', handleClear);
// Set initial styles and add hover effects
[downloadItem, clearItem].forEach(item => {
item.style.color = defaultColor;
item.style.backgroundColor = defaultBgColor;
// Add hover effects
item.addEventListener('mouseenter', () => {
item.style.color = defaultBgColor;
item.style.backgroundColor = defaultColor;
});
item.addEventListener('mouseleave', () => {
item.style.color = defaultColor;
item.style.backgroundColor = defaultBgColor;
});
});
// Add items to menu
accountMenu.appendChild(downloadItem);
accountMenu.appendChild(clearItem);
return { downloadButton: downloadItem, clearButton: clearItem };
};
// Remove UI elements
const removeUI = () => {
const downloadItem = document.getElementById('AccountDownloadHands');
const clearItem = document.getElementById('AccountClearHands');
if (downloadItem) downloadItem.remove();
if (clearItem) clearItem.remove();
};
// Handle download click
const handleDownload = async () => {
if (!username) {
console.error('[PHHS] No username available for download');
return;
}
let db = null;
try {
const dbName = getDBName(username);
// Open database
db = await new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, dbVersion);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
// Check if store exists
if (!db.objectStoreNames.contains(STORE_NAME)) {
alert('No hand histories available.');
return;
}
// Get all hands
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const hands = await new Promise((resolve, reject) => {
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
if (hands.length === 0) {
alert('No hand histories available.');
return;
}
// Create a file for each table and session.
// The hand number, e.g., 1234-5, has two parts:
// First part: absolute hand number across all tables
// Second part: hand number for the current session on a table, starting from 1.
try {
const zip = new JSZip();
// Group hands by table and session
const sessions = new Map(); // Map<tableId, Map<sessionStart, hands[]>>
// First pass: Initialize tables and their sessions
hands.forEach(hand => {
const { tableId, handNumber } = hand;
const [absoluteHandNumber, sessionHandNumber] = handNumber.split('-').map(Number);
if (!sessions.has(tableId)) {
sessions.set(tableId, new Map());
}
if (sessionHandNumber === 1) {
sessions.get(tableId).set(absoluteHandNumber, []);
}
});
// Second pass: Add hands to their sessions
hands.forEach(hand => {
const { tableId, handNumber } = hand;
const [absoluteHandNumber] = handNumber.split('-').map(Number);
const tableMap = sessions.get(tableId);
const sessionStarts = [...tableMap.keys()].filter(start => start <= absoluteHandNumber);
let sessionStart;
if (sessionStarts.length === 0) {
// If no valid session start found, create a new session starting at this hand
sessionStart = absoluteHandNumber;
tableMap.set(sessionStart, []);
} else {
sessionStart = Math.max(...sessionStarts);
}
// Ensure the session array exists
if (!tableMap.has(sessionStart)) {
tableMap.set(sessionStart, []);
}
tableMap.get(sessionStart).push(hand);
});
// Sort sessions by start time and create files
for (const [tableId, tableSessions] of sessions) {
// Convert to array and sort by session start time
const sortedSessions = [...tableSessions.entries()].sort(([a], [b]) => a - b);
for (const [sessionStart, sessionHands] of sortedSessions) {
if (sessionHands.length === 0) continue; // Skip empty sessions
// Sort hands within session by absolute hand number
sessionHands.sort((a, b) => {
const [aAbs] = a.handNumber.split('-').map(Number);
const [bAbs] = b.handNumber.split('-').map(Number);
return aAbs - bAbs;
});
// Concatenate hands in a session
const sessionContent = sessionHands.map(hand => hand.content).join('\n\n');
const filename = `${tableId} - Session starting ${sessionStart}.txt`;
zip.file(filename, sessionContent);
}
}
const blob = await zip.generateAsync({
type: "blob",
compression: "DEFLATE"
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `poker_hands_${username}_${new Date().toISOString().split('T')[0]}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
console.error('Error creating zip:', e);
}
} catch (error) {
console.error('[PHHS] Error during download:', error);
} finally {
if (db) {
db.close();
}
}
};
// Handle clear click
const handleClear = async () => {
if (!username) {
return;
}
let db = null;
try {
const dbName = getDBName(username);
// Open database
db = await new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, dbVersion);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
// Check if store exists
if (!db.objectStoreNames.contains(STORE_NAME)) {
alert('No hand histories available.');
return;
}
// Get count and size before confirming
const transaction = db.transaction([STORE_NAME], 'readonly');
const store = transaction.objectStore(STORE_NAME);
const hands = await new Promise((resolve, reject) => {
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
if (hands.length === 0) {
alert('No hand histories available.');
return;
}
const totalBytes = hands.reduce((sum, hand) => sum + hand.content.length, 0);
const size = formatSize(totalBytes);
if (!confirm(`Are you sure you want to clear all stored hands?\n\nThis will delete ${hands.length} hands (${size}).`)) {
return;
}
// Clear all hands
const clearTransaction = db.transaction([STORE_NAME], 'readwrite');
const clearStore = clearTransaction.objectStore(STORE_NAME);
await new Promise((resolve, reject) => {
const request = clearStore.clear();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
} catch (error) {
console.error('Error clearing hands:', error);
} finally {
if (db) {
db.close();
}
}
};
// Database helper
const withDB = async (username, callback) => {
if (!username) {
console.error('[PHHS] DB operation attempted without username');
return null;
}
let db = null;
try {
const dbName = getDBName(username);
db = await new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, dbVersion);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
return await callback(db);
} catch (error) {
console.error('[PHHS] Database error:', error);
throw error;
} finally {
if (db) {
db.close();
}
}
};
// Initialize database for a user
const initializeDB = (username) => {
return new Promise((resolve, reject) => {
const dbName = getDBName(username);
const request = indexedDB.open(dbName, dbVersion);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (event.oldVersion < 1) {
db.createObjectStore(STORE_NAME, { keyPath: ['tableId', 'handNumber'] });
return;
}
if (event.oldVersion < 2) {
const transaction = event.target.transaction;
const oldStore = transaction.objectStore(STORE_NAME);
oldStore.getAll().onsuccess = (event) => {
const records = event.target.result;
// Delete old store
db.deleteObjectStore(STORE_NAME);
// Create new store with composite key
const newStore = db.createObjectStore(STORE_NAME, {
keyPath: ['tableId', 'handNumber']
});
// Migrate each record
records.forEach(record => {
// Split out the tableId and handNumber from the filename
const match = record.filename.match(/(.*)-([^-]+-[^-]+)\.txt$/);
if (match) {
const [_, tableId, handNumber] = match;
newStore.add({
tableId,
handNumber,
content: record.content,
contentType: record.contentType
});
}
});
};
}
};
request.onsuccess = () => {
const db = request.result;
db.close();
resolve();
};
request.onerror = (event) => {
console.error('Database initialization error:', event.target.error);
reject(event.target.error);
};
});
};
// Update UI based on username changes
const updateUI = async (newUsername) => {
if (previousUsername !== newUsername) {
initializedDatabases.clear();
}
// Username changed from null to non-null
if (!previousUsername && newUsername) {
const elements = createUI();
if (elements) {
const { downloadButton, clearButton } = elements;
await initializeDB(newUsername);
}
}
// Username changed from non-null to null
else if (previousUsername && !newUsername) {
removeUI();
}
// Username changed to different user
else if (previousUsername && newUsername && previousUsername !== newUsername) {
await initializeDB(newUsername);
}
previousUsername = newUsername;
};
// Helper function to format size
const formatSize = (bytes) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
};
})();