// ==UserScript== // @name Game Datacards - Image Export/Import (ZIP) // @namespace local // @match https://game-datacards.eu/* // @grant none // @version 2.1 // @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/datacards-import-export.user.js // @updateURL https://git.luxick.de/luxick/scripts/raw/branch/master/js/datacards-import-export.user.js // ==/UserScript== (function () { 'use strict'; 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 addButtons() { 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; } // The app renders after load, so retry until the header exists. if (!addButtons()) { const obs = new MutationObserver(() => { if (addButtons()) obs.disconnect(); }); obs.observe(document.body, { childList: true, subtree: true }); } })();