// ==UserScript== // @name PlayDate Companion // @namespace playdate // @version 3.2 // @description Imports Steam activation dates, GOG/EA purchase dates, and SteamGifts wins into PlayDate // @icon https://raw.githubusercontent.com/RobbyRatpoison/PlayDate-Library-Manager/main/static/img/favicon.png // @match https://help.steampowered.com/* // @match https://www.gog.com/en/account/settings/orders* // @match https://myaccount.ea.com/am/ui/payment-wallet/order-history* // @match https://www.steamgifts.com/giveaways/won* // @match https://www.steamgifts.com/user/*/giveaways/won* // @updateURL https://raw.githubusercontent.com/RobbyRatpoison/PlayDate-Library-Manager/main/steam_date_import.user.js // @downloadURL https://raw.githubusercontent.com/RobbyRatpoison/PlayDate-Library-Manager/main/steam_date_import.user.js // @license MIT // @grant GM_xmlhttpRequest // @connect localhost // ==/UserScript== (function () { 'use strict'; // PlayDate serves on 5000, or on 2468 when something else (e.g. macOS's // AirPlay Receiver) already holds 5000. Probe both once and remember which // answers. Keep this list in sync with main.py's PORT / FALLBACK_PORT. const PD_PORTS = [5000, 2468]; let PD_BASE = 'http://localhost:' + PD_PORTS[0]; const pdReady = (async () => { for (const port of PD_PORTS) { const base = 'http://localhost:' + port; const isPlayDate = await new Promise(resolve => { GM_xmlhttpRequest({ method: 'GET', url: base + '/api/update-status', timeout: 2000, onload: r => { try { resolve('current_version' in JSON.parse(r.responseText)); } catch (e) { resolve(false); } }, onerror: () => resolve(false), ontimeout: () => resolve(false), }); }); if (isPlayDate) { PD_BASE = base; return; } } })(); // EA activates by path only — the auth redirect strips ?ref=playdate if (window.location.hostname === 'myaccount.ea.com' && window.location.pathname.includes('/payment-wallet/order-history')) { runEA(); return; } // SteamGifts wins — button-triggered, or auto when PlayDate opens the tab // with ?playdate_sync=1. Has its own trigger, not ?ref=playdate. if (window.location.hostname === 'www.steamgifts.com' && window.location.pathname.includes('/giveaways/won')) { runSteamGifts(); return; } const params = new URLSearchParams(window.location.search); if (params.get('ref') !== 'playdate') return; // ========================================================================= // GOG Orders page — scrape purchase dates from all order history pages // ========================================================================= if (window.location.hostname === 'www.gog.com') { runGog(); return; } const isBulk = params.get('bulk') === '1'; // GM_xmlhttpRequest bypasses the page's Content Security Policy, which // blocks fetch() to localhost. Use this for all PlayDate API calls. // fetch() is still used for same-origin Steam Help page requests. function pdFetch(method, path, body) { return pdReady.then(() => new Promise((resolve, reject) => { GM_xmlhttpRequest({ method, url: PD_BASE + path, headers: { 'Content-Type': 'application/json' }, data: body !== undefined ? JSON.stringify(body) : undefined, onload: resolve, onerror: reject, ontimeout: reject, }); })); } // ── Parse "Oct 1, 2017" or "Mar 25" → "2017-10-01" ────────────────────── function parseDate(str) { str = str.trim(); if (!/\d{4}/.test(str)) str = `${str}, ${new Date().getFullYear()}`; const d = new Date(str); if (isNaN(d.getTime())) return null; return [ d.getFullYear(), String(d.getMonth() + 1).padStart(2, '0'), String(d.getDate()).padStart(2, '0'), ].join('-'); } // ── Find earliest activation date in a DOM document ─────────────────────── function parseDateFromDoc(doc) { const dates = []; doc.querySelectorAll('.LineItemRow span:first-child').forEach(el => { const text = el.textContent.replace(/\u00a0/g, ' ').split('-')[0].trim(); const parsed = parseDate(text); if (parsed) dates.push(parsed); }); if (dates.length === 0) { doc.querySelectorAll('.account_details .help_highlight_text').forEach(el => { if (el.textContent.trim() === 'Activated:') { const val = el.nextElementSibling; if (val) { const parsed = parseDate(val.textContent); if (parsed) dates.push(parsed); } } }); } return dates.length ? dates.sort()[0] : null; } // ── Small status banner (single-game mode) ──────────────────────────────── function showBanner(msg, color) { const existing = document.getElementById('pd-banner'); if (existing) existing.remove(); const banner = document.createElement('div'); banner.id = 'pd-banner'; banner.textContent = msg; Object.assign(banner.style, { position: 'fixed', bottom: '20px', right: '20px', background: '#1a2332', border: `1px solid ${color}`, color: '#c7d5e0', padding: '10px 16px', borderRadius: '8px', fontSize: '0.88rem', zIndex: '99999', maxWidth: '340px', boxShadow: '0 4px 16px rgba(0,0,0,0.5)', }); document.body.appendChild(banner); setTimeout(() => banner.remove(), 6000); } // ========================================================================= // Single-game mode (edit modal ↗ link) // ========================================================================= if (!isBulk) { if (!window.location.pathname.includes('HelpWithGame')) return; const appid = parseInt(params.get('appid')); if (!appid) return; async function checkAccountThenRun() { let pageSteamId = null; try { if (window.HelpWizard && window.HelpWizard.m_steamid) pageSteamId = String(window.HelpWizard.m_steamid); } catch (e) {} if (pageSteamId) { try { const r = await pdFetch('GET', '/api/active-steam-id'); const d = JSON.parse(r.responseText); if (d.steam_id && String(d.steam_id) !== pageSteamId) { showBanner( `Account mismatch — Steam is logged in as ${pageSteamId} but PlayDate is configured for ${d.steam_id}. Import aborted.`, '#c97c00' ); return; } } catch (e) { /* PlayDate unreachable — proceed */ } } tryRun(); } let _attempts = 0; function tryRun() { _attempts++; const date = parseDateFromDoc(document); if (date) { sendSingleDate(date); return; } if (_attempts < 20) setTimeout(tryRun, 500); else showBanner('No activation date found on this page.', '#c97c00'); } async function sendSingleDate(date) { try { const res = await pdFetch('POST', '/api/pending-date', { appid, date }); if (res.status === 200) { showBanner(`Date sent: ${date}`, '#1a7f4b'); } else { showBanner(`PlayDate error: ${res.status}`, '#c97c00'); } } catch (e) { showBanner('Could not reach PlayDate. Make sure it is running.', '#c97c00'); } } checkAccountThenRun(); return; } // ========================================================================= // Bulk mode — stay on this tab, fetch each game's Help page in the background // ========================================================================= // ── Full-page overlay ───────────────────────────────────────────────────── const overlay = document.createElement('div'); overlay.id = 'pd-bulk-overlay'; Object.assign(overlay.style, { position: 'fixed', inset: '0', background: 'rgba(10,15,25,0.93)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', zIndex: '99999', color: '#c7d5e0', fontFamily: "'Segoe UI', sans-serif", gap: '12px', }); overlay.innerHTML = `