// ==UserScript== // @name Game Datacards - Improved // @namespace local // @version 1.0 // @description Adds card image export/import (ZIP) to the header and a button to rotate card backs for printing // @match *://game-datacards.eu/* // @grant none // @run-at document-idle // @require https://cdn.jsdelivr.net/npm/fflate@0.8.3/umd/index.js // @downloadURL https://git.luxick.de/luxick/scripts/raw/branch/master/js/game-datcards-improved.user.js // @updateURL https://git.luxick.de/luxick/scripts/raw/branch/master/js/game-datcards-improved.user.js // ==/UserScript== (function () { 'use strict'; // --------------------------------------------------------------------------- // Image export / import (IndexedDB <-> ZIP) // --------------------------------------------------------------------------- const DB_NAME = 'CardImagesDB'; const STORE = 'images'; function openDB() { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } function getAll(db) { return new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readonly'); const req = tx.objectStore(STORE).getAll(); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } function putAll(db, records) { return new Promise((resolve, reject) => { const tx = db.transaction(STORE, 'readwrite'); const store = tx.objectStore(STORE); records.forEach((r) => store.put(r)); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } function blobToU8(blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(new Uint8Array(reader.result)); reader.onerror = () => reject(reader.error); reader.readAsArrayBuffer(blob); }); } function extFromType(type) { if (!type) return 'bin'; const map = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' }; return map[type] || type.split('/')[1] || 'bin'; } async function doExport() { const db = await openDB(); const records = await getAll(db); if (!records.length) { alert('No images found in the database.'); return; } const files = {}; const manifest = []; for (const rec of records) { if (!(rec.image instanceof Blob)) { console.warn('Record image is not a Blob, skipping binary for', rec.id); } const ext = extFromType(rec.type || (rec.image && rec.image.type)); const imgName = `images/${rec.id}.${ext}`; if (rec.image instanceof Blob) { files[imgName] = await blobToU8(rec.image); } const meta = { ...rec }; delete meta.image; meta.__file = imgName; manifest.push(meta); } files['manifest.json'] = fflate.strToU8(JSON.stringify(manifest, null, 2)); const zipped = fflate.zipSync(files, { level: 6 }); const blob = new Blob([zipped], { type: 'application/zip' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'game-datacards-images.zip'; a.click(); URL.revokeObjectURL(url); console.log(`Exported ${records.length} images to ZIP.`); } async function doImport() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.zip,application/zip'; input.onchange = async () => { const file = input.files[0]; if (!file) return; const buf = new Uint8Array(await file.arrayBuffer()); const entries = fflate.unzipSync(buf); if (!entries['manifest.json']) { alert('No manifest.json in the ZIP. Cannot import.'); return; } const manifest = JSON.parse(fflate.strFromU8(entries['manifest.json'])); const records = []; for (const meta of manifest) { const rec = { ...meta }; const fileName = meta.__file; delete rec.__file; if (fileName && entries[fileName]) { rec.image = new Blob([entries[fileName]], { type: rec.type || 'application/octet-stream' }); rec.size = entries[fileName].byteLength; } records.push(rec); } const db = await openDB(); await putAll(db, records); alert(`Imported ${records.length} images. Reload the page to see them.`); }; input.click(); } const EXPORT_ICON = ''; const IMPORT_ICON = ''; function mkBtn(id, title, iconSvg, fn) { const b = document.createElement('button'); b.id = id; b.className = 'app-header-icon-btn'; b.title = title; b.setAttribute('aria-label', title); b.innerHTML = iconSvg; b.onclick = () => fn().catch((e) => alert('Error: ' + e.message)); return b; } function addHeaderButtons() { if (document.getElementById('gdc-img-export')) return true; const settingsBtn = document.querySelector('.app-header-settings-btn'); if (!settingsBtn) return false; // Wrap our two buttons in a header group so spacing matches the rest. const group = document.createElement('div'); group.className = 'app-header-group'; group.appendChild(mkBtn('gdc-img-export', 'Export card images', EXPORT_ICON, doExport)); group.appendChild(mkBtn('gdc-img-import', 'Import card images', IMPORT_ICON, doImport)); // Insert the group right before the settings button's group, // so the buttons sit to the left of the cog. const settingsGroup = settingsBtn.closest('.app-header-group'); settingsGroup.parentNode.insertBefore(group, settingsGroup); return true; } // --------------------------------------------------------------------------- // Rotate card backs for printing // --------------------------------------------------------------------------- function rotateCardBacks() { var wrappers = document.querySelectorAll('.unit-card-back-wrapper'); var count = 0; wrappers.forEach(function (wrapper) { var outer = wrapper.parentElement; if (!outer) return; var current = outer.style.rotate; if (current === '180deg') { outer.style.rotate = '0deg'; } else { outer.style.rotate = '180deg'; } count++; }); console.log('Rotated ' + count + ' card back(s).'); return count; } function createRotateButton() { var button = document.createElement('button'); button.id = 'rotate-card-backs-button'; button.textContent = 'Rotate card backs'; button.style.display = 'block'; button.style.width = '100%'; button.style.marginTop = '10px'; button.style.padding = '8px 12px'; button.style.background = '#381a3a'; button.style.color = 'white'; button.style.border = 'none'; button.style.borderRadius = '4px'; button.style.cursor = 'pointer'; button.style.fontFamily = 'sans-serif'; button.style.fontSize = '13px'; button.addEventListener('click', rotateCardBacks); return button; } function addRotateButton() { if (document.getElementById('rotate-card-backs-button')) return true; var sidebar = document.querySelector('.print-settings-scroll'); if (!sidebar) return false; sidebar.appendChild(createRotateButton()); console.log('Rotate card backs button added to the sidebar.'); return true; } // --------------------------------------------------------------------------- // Bootstrap // --------------------------------------------------------------------------- // The app renders after load, and the print sidebar only exists on the print // view, so keep watching the DOM until both mount points have been handled. var headerDone = addHeaderButtons(); var sidebarDone = addRotateButton(); if (!headerDone || !sidebarDone) { var observer = new MutationObserver(function () { if (!headerDone) headerDone = addHeaderButtons(); if (!sidebarDone) sidebarDone = addRotateButton(); if (headerDone && sidebarDone) observer.disconnect(); }); observer.observe(document.documentElement, { childList: true, subtree: true }); } })();