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 });
+ }
+})();