From eb08df472c2460c2dee2a59d9aba954df2bb58bd Mon Sep 17 00:00:00 2001 From: luxick Date: Tue, 7 Jul 2026 20:29:10 +0200 Subject: [PATCH] Add image export/import for game-datacards.eu --- js/datacards-import-export.user.js | 183 +++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 js/datacards-import-export.user.js diff --git a/js/datacards-import-export.user.js b/js/datacards-import-export.user.js new file mode 100644 index 0000000..5c0f1ca --- /dev/null +++ b/js/datacards-import-export.user.js @@ -0,0 +1,183 @@ +// ==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 }); + } +})();